mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +00:00
Configure analytics explicitly instead of discovering it
PosthogAnalytics read its own configuration out of the environment on first use: the URL parameters, config.json, and the widget global. An embedded Element Call has none of those to offer, and analytics that configure themselves cannot be switched off by a host that does its own reporting. Take an AnalyticsConfig through PosthogAnalytics.configure() instead, called from the initializer once the config has loaded. Unconfigured analytics stay off. Note the two halves of that config are decided differently, and have to be: where the credentials come from depends on the package, but who owns the user's analytics identity depends on how Element Call is running, since the full package can be used as a widget too. Drop the widget check around cryptoVersion, which never did anything — widget mode never initialises crypto, so getCrypto() is already undefined there. Move the tests covering which package reads which credential source onto analyticsConfigFromEnvironment, where that decision now lives.
This commit is contained in:
@@ -22,75 +22,121 @@ import {
|
|||||||
PosthogAnalytics,
|
PosthogAnalytics,
|
||||||
} from "./PosthogAnalytics";
|
} from "./PosthogAnalytics";
|
||||||
import { mockConfig } from "../utils/test";
|
import { mockConfig } from "../utils/test";
|
||||||
|
import { analyticsConfigFromEnvironment } from "../initializer";
|
||||||
|
|
||||||
describe("PosthogAnalytics", () => {
|
describe("PosthogAnalytics", () => {
|
||||||
describe("embedded package", () => {
|
describe("enablement", () => {
|
||||||
beforeAll(() => {
|
|
||||||
vi.stubEnv("VITE_PACKAGE", "embedded");
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockConfig({});
|
|
||||||
window.location.hash = "#";
|
|
||||||
PosthogAnalytics.resetInstance();
|
PosthogAnalytics.resetInstance();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
it("stays off until it is configured", () => {
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not create instance without config value or URL params", () => {
|
|
||||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores config value and does not create instance", () => {
|
it("stays off when configured without credentials", () => {
|
||||||
mockConfig({
|
PosthogAnalytics.configure({ matrixBackend: "jssdk" });
|
||||||
posthog: {
|
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||||
api_host: "https://api.example.com.localhost",
|
});
|
||||||
api_key: "api_key",
|
|
||||||
},
|
it("stays off when given only a key", () => {
|
||||||
|
PosthogAnalytics.configure({
|
||||||
|
matrixBackend: "jssdk",
|
||||||
|
apiKey: "api_key",
|
||||||
});
|
});
|
||||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses URL params if both set", () => {
|
it("turns on when given both a key and a host", () => {
|
||||||
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
|
PosthogAnalytics.configure({
|
||||||
|
matrixBackend: "jssdk",
|
||||||
|
apiKey: "api_key",
|
||||||
|
apiHost: "https://api.example.com.localhost",
|
||||||
|
});
|
||||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
|
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("full package", () => {
|
// Which of the URL and config.json the credentials come from is a deliberate
|
||||||
beforeAll(() => {
|
// policy: an embedder is responsible for its own users' telemetry, so it must
|
||||||
vi.stubEnv("VITE_PACKAGE", "full");
|
// not pick up the deployment's, and vice versa.
|
||||||
});
|
describe("analyticsConfigFromEnvironment", () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockConfig({});
|
mockConfig({});
|
||||||
window.location.hash = "#";
|
window.location.hash = "#";
|
||||||
PosthogAnalytics.resetInstance();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not create instance without config value", () => {
|
const urlCredentials = `posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=url_key`;
|
||||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
const configCredentials = {
|
||||||
});
|
posthog: {
|
||||||
|
api_host: "https://config.example.com.localhost",
|
||||||
|
api_key: "config_key",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
it("ignores URL params and does not create instance", () => {
|
describe("embedded package", () => {
|
||||||
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
|
beforeAll(() => {
|
||||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
vi.stubEnv("VITE_PACKAGE", "embedded");
|
||||||
});
|
|
||||||
|
|
||||||
it("creates instance with config value", () => {
|
|
||||||
mockConfig({
|
|
||||||
posthog: {
|
|
||||||
api_host: "https://api.example.com.localhost",
|
|
||||||
api_key: "api_key",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
|
|
||||||
|
it("has no credentials without URL params", () => {
|
||||||
|
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the credentials from the URL", () => {
|
||||||
|
window.location.hash = `#?${urlCredentials}`;
|
||||||
|
expect(analyticsConfigFromEnvironment()).toMatchObject({
|
||||||
|
apiKey: "url_key",
|
||||||
|
apiHost: "https://url.example.com.localhost",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores the deployment's config", () => {
|
||||||
|
mockConfig(configCredentials);
|
||||||
|
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("full package", () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.stubEnv("VITE_PACKAGE", "full");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no credentials without config", () => {
|
||||||
|
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the credentials from the config", () => {
|
||||||
|
mockConfig(configCredentials);
|
||||||
|
expect(analyticsConfigFromEnvironment()).toMatchObject({
|
||||||
|
apiKey: "config_key",
|
||||||
|
apiHost: "https://config.example.com.localhost",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores the URL params", () => {
|
||||||
|
window.location.hash = `#?${urlCredentials}`;
|
||||||
|
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Who owns the user's analytics identity depends on how Element Call is
|
||||||
|
// running, not on which package it was built as.
|
||||||
|
it("reports the embedded backend when running as a widget", () => {
|
||||||
|
vi.stubEnv("VITE_PACKAGE", "full");
|
||||||
|
window.location.hash = `#?widgetId=id&parentUrl=${encodeURIComponent("https://host.example.com.localhost")}&posthogUserId=given_id`;
|
||||||
|
expect(analyticsConfigFromEnvironment()).toMatchObject({
|
||||||
|
matrixBackend: "embedded",
|
||||||
|
hostAnalyticsId: "given_id",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the jssdk backend when running standalone", () => {
|
||||||
|
expect(analyticsConfigFromEnvironment().matrixBackend).toBe("jssdk");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -204,22 +250,13 @@ describe("PosthogAnalytics", () => {
|
|||||||
// posthog-js bumps renaming/removing the hook. The filter logic itself is
|
// posthog-js bumps renaming/removing the hook. The filter logic itself is
|
||||||
// covered by the applyPrivacyFilters block above.
|
// covered by the applyPrivacyFilters block above.
|
||||||
describe("posthog.init wiring", () => {
|
describe("posthog.init wiring", () => {
|
||||||
beforeAll(() => {
|
|
||||||
vi.stubEnv("VITE_PACKAGE", "full");
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockConfig({
|
|
||||||
posthog: {
|
|
||||||
api_host: "https://api.example.com.localhost",
|
|
||||||
api_key: "api_key",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
PosthogAnalytics.resetInstance();
|
PosthogAnalytics.resetInstance();
|
||||||
});
|
PosthogAnalytics.configure({
|
||||||
|
matrixBackend: "jssdk",
|
||||||
afterAll(() => {
|
apiKey: "api_key",
|
||||||
vi.unstubAllEnvs();
|
apiHost: "https://api.example.com.localhost",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes events through the privacy filter via before_send", () => {
|
it("passes events through the privacy filter via before_send", () => {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
|||||||
import { type MatrixClient } from "matrix-js-sdk";
|
import { type MatrixClient } from "matrix-js-sdk";
|
||||||
import { type Subscription } from "rxjs";
|
import { type Subscription } from "rxjs";
|
||||||
|
|
||||||
import { widget } from "../widget";
|
|
||||||
import {
|
import {
|
||||||
CallEndedTracker,
|
CallEndedTracker,
|
||||||
CallStartedTracker,
|
CallStartedTracker,
|
||||||
@@ -29,8 +28,6 @@ import {
|
|||||||
CallConnectDurationTracker,
|
CallConnectDurationTracker,
|
||||||
CallReconnectingTracker,
|
CallReconnectingTracker,
|
||||||
} from "./PosthogEvents";
|
} from "./PosthogEvents";
|
||||||
import { Config } from "../config/Config";
|
|
||||||
import { getUrlParams } from "../UrlParams";
|
|
||||||
import { optInAnalytics } from "../settings/settings";
|
import { optInAnalytics } from "../settings/settings";
|
||||||
|
|
||||||
/* Posthog analytics tracking.
|
/* Posthog analytics tracking.
|
||||||
@@ -140,6 +137,27 @@ interface PlatformProperties {
|
|||||||
cryptoVersion?: string;
|
cryptoVersion?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How analytics reporting should be set up, supplied by whoever is starting
|
||||||
|
* Element Call rather than discovered from the page it happens to be on.
|
||||||
|
*/
|
||||||
|
export interface AnalyticsConfig {
|
||||||
|
/** The PostHog project key. Without one, analytics stay switched off. */
|
||||||
|
apiKey?: string;
|
||||||
|
apiHost?: string;
|
||||||
|
/**
|
||||||
|
* How Element Call reaches Matrix. When `embedded`, the host owns the user's
|
||||||
|
* identity: it supplies the analytics ID, and Element Call must not store one
|
||||||
|
* in the user's account data.
|
||||||
|
*/
|
||||||
|
matrixBackend: "embedded" | "jssdk";
|
||||||
|
/** The analytics ID the host has assigned to this user, when embedded. */
|
||||||
|
hostAnalyticsId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Analytics are off until someone asks for them. */
|
||||||
|
const analyticsDisabled: AnalyticsConfig = { matrixBackend: "jssdk" };
|
||||||
|
|
||||||
export class PosthogAnalytics {
|
export class PosthogAnalytics {
|
||||||
/* Wrapper for Posthog analytics.
|
/* Wrapper for Posthog analytics.
|
||||||
* 3 modes of anonymity are supported, governed by this.anonymity
|
* 3 modes of anonymity are supported, governed by this.anonymity
|
||||||
@@ -167,13 +185,32 @@ export class PosthogAnalytics {
|
|||||||
private registrationType: RegistrationType = RegistrationType.Guest;
|
private registrationType: RegistrationType = RegistrationType.Guest;
|
||||||
private optInListener: Subscription | null = null;
|
private optInListener: Subscription | null = null;
|
||||||
|
|
||||||
|
private static analyticsConfig: AnalyticsConfig = analyticsDisabled;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up analytics reporting. Must be called before the instance is first
|
||||||
|
* used; without it, analytics stay switched off.
|
||||||
|
*/
|
||||||
|
public static configure(config: AnalyticsConfig): void {
|
||||||
|
if (this.internalInstance)
|
||||||
|
// Configuration is read once, when the instance is built, so arriving
|
||||||
|
// late means analytics are already running unconfigured.
|
||||||
|
logger.warn(
|
||||||
|
"Analytics were configured after they had already been started; the new configuration will not take effect",
|
||||||
|
);
|
||||||
|
this.analyticsConfig = config;
|
||||||
|
}
|
||||||
|
|
||||||
public static hasInstance(): boolean {
|
public static hasInstance(): boolean {
|
||||||
return Boolean(this.internalInstance);
|
return Boolean(this.internalInstance);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static get instance(): PosthogAnalytics {
|
public static get instance(): PosthogAnalytics {
|
||||||
if (!this.internalInstance) {
|
if (!this.internalInstance) {
|
||||||
this.internalInstance = new PosthogAnalytics(posthog);
|
this.internalInstance = new PosthogAnalytics(
|
||||||
|
posthog,
|
||||||
|
PosthogAnalytics.analyticsConfig,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return this.internalInstance;
|
return this.internalInstance;
|
||||||
}
|
}
|
||||||
@@ -181,20 +218,14 @@ export class PosthogAnalytics {
|
|||||||
public static resetInstance(): void {
|
public static resetInstance(): void {
|
||||||
// Reset the singleton instance
|
// Reset the singleton instance
|
||||||
this.internalInstance = null;
|
this.internalInstance = null;
|
||||||
|
this.analyticsConfig = analyticsDisabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
private constructor(private readonly posthog: PostHog) {
|
private constructor(
|
||||||
let apiKey: string | undefined;
|
private readonly posthog: PostHog,
|
||||||
let apiHost: string | undefined;
|
private readonly config: AnalyticsConfig,
|
||||||
if (import.meta.env.VITE_PACKAGE === "embedded") {
|
) {
|
||||||
// for the embedded package we always use the values from the URL as the widget host is responsible for analytics configuration
|
const { apiKey, apiHost } = config;
|
||||||
apiKey = getUrlParams().posthogApiKey ?? undefined;
|
|
||||||
apiHost = getUrlParams().posthogApiHost ?? undefined;
|
|
||||||
} else if (import.meta.env.VITE_PACKAGE === "full") {
|
|
||||||
// in full package it is the server responsible for the analytics
|
|
||||||
apiKey = Config.get().posthog?.api_key;
|
|
||||||
apiHost = Config.get().posthog?.api_host;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiKey && apiHost) {
|
if (apiKey && apiHost) {
|
||||||
const beforeSend = (event: CaptureResult | null): CaptureResult | null =>
|
const beforeSend = (event: CaptureResult | null): CaptureResult | null =>
|
||||||
@@ -225,15 +256,15 @@ export class PosthogAnalytics {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getPlatformProperties(): PlatformProperties {
|
private getPlatformProperties(): PlatformProperties {
|
||||||
const appVersion = import.meta.env.VITE_APP_VERSION || "dev";
|
const appVersion = import.meta.env.VITE_APP_VERSION || "dev";
|
||||||
return {
|
return {
|
||||||
appVersion,
|
appVersion,
|
||||||
matrixBackend: widget ? "embedded" : "jssdk",
|
matrixBackend: this.config.matrixBackend,
|
||||||
callBackend: "livekit",
|
callBackend: "livekit",
|
||||||
cryptoVersion: widget
|
// Undefined when Element Call has no crypto of its own, which is the case
|
||||||
? undefined
|
// whenever a host is doing the encrypting for it.
|
||||||
: window.matrixclient?.getCrypto()?.getVersion(),
|
cryptoVersion: window.matrixclient?.getCrypto()?.getVersion(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,8 +314,8 @@ export class PosthogAnalytics {
|
|||||||
// different devices to send the same ID.
|
// different devices to send the same ID.
|
||||||
let analyticsID = await this.getAnalyticsId();
|
let analyticsID = await this.getAnalyticsId();
|
||||||
try {
|
try {
|
||||||
if (!analyticsID && !widget) {
|
if (!analyticsID && this.config.matrixBackend !== "embedded") {
|
||||||
// only try setting up a new analytics ID in the standalone app.
|
// only mint an analytics ID when we are the ones storing it.
|
||||||
|
|
||||||
// Couldn't retrieve an analytics ID from user settings, so create one and set it on the server.
|
// Couldn't retrieve an analytics ID from user settings, so create one and set it on the server.
|
||||||
// Note there's a race condition here - if two devices do these steps at the same time, last write
|
// Note there's a race condition here - if two devices do these steps at the same time, last write
|
||||||
@@ -313,8 +344,8 @@ export class PosthogAnalytics {
|
|||||||
|
|
||||||
private async getAnalyticsId(): Promise<string | null> {
|
private async getAnalyticsId(): Promise<string | null> {
|
||||||
const client: MatrixClient = window.matrixclient;
|
const client: MatrixClient = window.matrixclient;
|
||||||
if (widget) {
|
if (this.config.matrixBackend === "embedded") {
|
||||||
return getUrlParams().posthogUserId;
|
return this.config.hostAnalyticsId ?? null;
|
||||||
} else {
|
} else {
|
||||||
const accountData = await client.getAccountDataFromServer(
|
const accountData = await client.getAccountDataFromServer(
|
||||||
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
|
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
|
||||||
@@ -324,7 +355,7 @@ export class PosthogAnalytics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async setAccountAnalyticsId(analyticsID: string): Promise<void> {
|
private async setAccountAnalyticsId(analyticsID: string): Promise<void> {
|
||||||
if (!widget) {
|
if (this.config.matrixBackend !== "embedded") {
|
||||||
const client = window.matrixclient;
|
const client = window.matrixclient;
|
||||||
|
|
||||||
// the analytics ID only needs to be set in the standalone version.
|
// the analytics ID only needs to be set in the standalone version.
|
||||||
@@ -362,7 +393,7 @@ export class PosthogAnalytics {
|
|||||||
// These properties will be subsequently passed in every event.
|
// These properties will be subsequently passed in every event.
|
||||||
//
|
//
|
||||||
// This only needs to be done once per page lifetime. Note that getPlatformProperties
|
// This only needs to be done once per page lifetime. Note that getPlatformProperties
|
||||||
this.platformSuperProperties = PosthogAnalytics.getPlatformProperties();
|
this.platformSuperProperties = this.getPlatformProperties();
|
||||||
this.registerSuperProperties({
|
this.registerSuperProperties({
|
||||||
...this.platformSuperProperties,
|
...this.platformSuperProperties,
|
||||||
registrationType:
|
registrationType:
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ import { platform } from "./Platform";
|
|||||||
import { isFailure } from "./utils/fetch";
|
import { isFailure } from "./utils/fetch";
|
||||||
import { initializeWidget } from "./widget";
|
import { initializeWidget } from "./widget";
|
||||||
import { enableExtendedLivekitLogs } from "./settings/settings.ts";
|
import { enableExtendedLivekitLogs } from "./settings/settings.ts";
|
||||||
|
import {
|
||||||
|
type AnalyticsConfig,
|
||||||
|
PosthogAnalytics,
|
||||||
|
} from "./analytics/PosthogAnalytics.ts";
|
||||||
import { i18n } from "./utils/i18n.ts";
|
import { i18n } from "./utils/i18n.ts";
|
||||||
|
|
||||||
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
|
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
|
||||||
@@ -98,6 +102,36 @@ const Backend = {
|
|||||||
},
|
},
|
||||||
} satisfies BackendModule;
|
} satisfies BackendModule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where analytics reporting is configured from.
|
||||||
|
*
|
||||||
|
* Note the two halves are decided differently, and deliberately so. *Where the
|
||||||
|
* PostHog credentials come from* depends on the package: an embedder passes
|
||||||
|
* them in through the URL because it is responsible for its own users'
|
||||||
|
* telemetry, whereas a standalone deployment is configured by whoever operates
|
||||||
|
* it. *Who owns the user's analytics identity*, on the other hand, depends on
|
||||||
|
* how Element Call is actually running right now — the full package can be used
|
||||||
|
* as a widget too.
|
||||||
|
*/
|
||||||
|
// Exported for testing
|
||||||
|
export function analyticsConfigFromEnvironment(): AnalyticsConfig {
|
||||||
|
const { posthogApiKey, posthogApiHost, posthogUserId, isWidget } =
|
||||||
|
getUrlParams();
|
||||||
|
return {
|
||||||
|
matrixBackend: isWidget ? "embedded" : "jssdk",
|
||||||
|
hostAnalyticsId: posthogUserId,
|
||||||
|
...(import.meta.env.VITE_PACKAGE === "embedded"
|
||||||
|
? {
|
||||||
|
apiKey: posthogApiKey ?? undefined,
|
||||||
|
apiHost: posthogApiHost ?? undefined,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
apiKey: Config.get().posthog?.api_key,
|
||||||
|
apiHost: Config.get().posthog?.api_host,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
enum LoadState {
|
enum LoadState {
|
||||||
None,
|
None,
|
||||||
Loading,
|
Loading,
|
||||||
@@ -241,6 +275,7 @@ export class Initializer {
|
|||||||
Config.init().then(
|
Config.init().then(
|
||||||
() => {
|
() => {
|
||||||
seedSettingsFromConfig(Config.get().media_quality);
|
seedSettingsFromConfig(Config.get().media_quality);
|
||||||
|
PosthogAnalytics.configure(analyticsConfigFromEnvironment());
|
||||||
this.loadStates.config = LoadState.Loaded;
|
this.loadStates.config = LoadState.Loaded;
|
||||||
this.initStep(resolve);
|
this.initStep(resolve);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user