diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json
index f5b3e798af..a23bfa5650 100644
--- a/apps/browser/src/_locales/en/messages.json
+++ b/apps/browser/src/_locales/en/messages.json
@@ -1997,5 +1997,32 @@
},
"environmentEditedReset": {
"message": "to reset to pre-configured settings"
+ },
+ "serverVersion": {
+ "message": "Server Version"
+ },
+ "selfHosted": {
+ "message": "Self-Hosted"
+ },
+ "thirdParty": {
+ "message": "Third-Party"
+ },
+ "thirdPartyServerMessage": {
+ "message": "Connected to third-party server implementation, $SERVERNAME$. Please verify bugs using the official server, or report them to the third-party server.",
+ "placeholders": {
+ "servername": {
+ "content": "$1",
+ "example": "ThirdPartyServerName"
+ }
+ }
+ },
+ "lastSeenOn": {
+ "message": "last seen on $DATE$",
+ "placeholders": {
+ "date": {
+ "content": "$1",
+ "example": "Jun 15, 2015"
+ }
+ }
}
}
diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts
index af1e78fc59..473ffb33a9 100644
--- a/apps/browser/src/popup/app.module.ts
+++ b/apps/browser/src/popup/app.module.ts
@@ -94,6 +94,7 @@ import { SendAddEditComponent } from "./send/send-add-edit.component";
import { SendGroupingsComponent } from "./send/send-groupings.component";
import { SendTypeComponent } from "./send/send-type.component";
import { ServicesModule } from "./services/services.module";
+import { AboutComponent } from "./settings/about.component";
import { ExcludedDomainsComponent } from "./settings/excluded-domains.component";
import { ExportComponent } from "./settings/export.component";
import { FolderAddEditComponent } from "./settings/folder-add-edit.component";
@@ -242,6 +243,7 @@ registerLocaleData(localeZhTw, "zh-TW");
ViewCustomFieldsComponent,
RemovePasswordComponent,
VaultSelectComponent,
+ AboutComponent,
],
providers: [CurrencyPipe, DatePipe],
bootstrap: [AppComponent],
diff --git a/apps/browser/src/popup/settings/about.component.html b/apps/browser/src/popup/settings/about.component.html
new file mode 100644
index 0000000000..ed2ceb40be
--- /dev/null
+++ b/apps/browser/src/popup/settings/about.component.html
@@ -0,0 +1,52 @@
+
diff --git a/apps/browser/src/popup/settings/about.component.ts b/apps/browser/src/popup/settings/about.component.ts
new file mode 100644
index 0000000000..338a00e268
--- /dev/null
+++ b/apps/browser/src/popup/settings/about.component.ts
@@ -0,0 +1,25 @@
+import { Component } from "@angular/core";
+import { Observable } from "rxjs";
+
+import { ConfigServiceAbstraction } from "@bitwarden/common/abstractions/config/config.service.abstraction";
+import { ServerConfig } from "@bitwarden/common/abstractions/config/server-config";
+import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service";
+
+import { BrowserApi } from "../../browser/browserApi";
+
+@Component({
+ selector: "app-about",
+ templateUrl: "about.component.html",
+})
+export class AboutComponent {
+ serverConfig$: Observable;
+
+ year = new Date().getFullYear();
+ version = BrowserApi.getApplicationVersion();
+ isCloud: boolean;
+
+ constructor(configService: ConfigServiceAbstraction, environmentService: EnvironmentService) {
+ this.serverConfig$ = configService.serverConfig$;
+ this.isCloud = environmentService.isCloud();
+ }
+}
diff --git a/apps/browser/src/popup/settings/settings.component.ts b/apps/browser/src/popup/settings/settings.component.ts
index 029812ba49..9728ff2a5d 100644
--- a/apps/browser/src/popup/settings/settings.component.ts
+++ b/apps/browser/src/popup/settings/settings.component.ts
@@ -20,6 +20,8 @@ import { BiometricErrors, BiometricErrorTypes } from "../../models/biometricErro
import { SetPinComponent } from "../components/set-pin.component";
import { PopupUtilsService } from "../services/popup-utils.service";
+import { AboutComponent } from "./about.component";
+
const RateUrls = {
[DeviceType.ChromeExtension]:
"https://chrome.google.com/webstore/detail/bitwarden-free-password-m/nngceckbapebfimnlniiiahkandclblb/reviews",
@@ -377,26 +379,7 @@ export class SettingsComponent implements OnInit {
}
about() {
- const year = new Date().getFullYear();
- const versionText = document.createTextNode(
- this.i18nService.t("version") + ": " + BrowserApi.getApplicationVersion()
- );
- const div = document.createElement("div");
- div.innerHTML =
- `
- Bitwarden
© Bitwarden Inc. 2015-` +
- year +
- `
`;
- div.appendChild(versionText);
-
- Swal.fire({
- heightAuto: false,
- buttonsStyling: false,
- html: div,
- showConfirmButton: false,
- showCancelButton: true,
- cancelButtonText: this.i18nService.t("close"),
- });
+ this.modalService.open(AboutComponent);
}
async fingerprint() {
diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts
index 9ca75fe2da..b76ad9a590 100644
--- a/libs/angular/src/services/jslib-services.module.ts
+++ b/libs/angular/src/services/jslib-services.module.ts
@@ -12,6 +12,8 @@ import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/abstrac
import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/abstractions/broadcaster.service";
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/abstractions/cipher.service";
import { CollectionService as CollectionServiceAbstraction } from "@bitwarden/common/abstractions/collection.service";
+import { ConfigApiServiceAbstraction } from "@bitwarden/common/abstractions/config/config-api.service.abstraction";
+import { ConfigServiceAbstraction } from "@bitwarden/common/abstractions/config/config.service.abstraction";
import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/abstractions/crypto.service";
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/abstractions/cryptoFunction.service";
import { EnvironmentService as EnvironmentServiceAbstraction } from "@bitwarden/common/abstractions/environment.service";
@@ -66,6 +68,8 @@ import { AuditService } from "@bitwarden/common/services/audit.service";
import { AuthService } from "@bitwarden/common/services/auth.service";
import { CipherService } from "@bitwarden/common/services/cipher.service";
import { CollectionService } from "@bitwarden/common/services/collection.service";
+import { ConfigApiService } from "@bitwarden/common/services/config/config-api.service";
+import { ConfigService } from "@bitwarden/common/services/config/config.service";
import { ConsoleLogService } from "@bitwarden/common/services/consoleLog.service";
import { CryptoService } from "@bitwarden/common/services/crypto.service";
import { EncryptService } from "@bitwarden/common/services/encrypt.service";
@@ -530,6 +534,16 @@ import { ValidationService } from "./validation.service";
useClass: OrganizationApiService,
deps: [ApiServiceAbstraction],
},
+ {
+ provide: ConfigServiceAbstraction,
+ useClass: ConfigService,
+ deps: [StateServiceAbstraction, ConfigApiServiceAbstraction],
+ },
+ {
+ provide: ConfigApiServiceAbstraction,
+ useClass: ConfigApiService,
+ deps: [ApiServiceAbstraction],
+ },
],
})
export class JslibServicesModule {}
diff --git a/libs/common/src/abstractions/config/config-api.service.abstraction.ts b/libs/common/src/abstractions/config/config-api.service.abstraction.ts
new file mode 100644
index 0000000000..3ebd79e4fc
--- /dev/null
+++ b/libs/common/src/abstractions/config/config-api.service.abstraction.ts
@@ -0,0 +1,5 @@
+import { ServerConfigResponse } from "@bitwarden/common/models/response/server-config-response";
+
+export abstract class ConfigApiServiceAbstraction {
+ get: () => Promise;
+}
diff --git a/libs/common/src/abstractions/config/config.service.abstraction.ts b/libs/common/src/abstractions/config/config.service.abstraction.ts
new file mode 100644
index 0000000000..ee9f946c7f
--- /dev/null
+++ b/libs/common/src/abstractions/config/config.service.abstraction.ts
@@ -0,0 +1,7 @@
+import { Observable } from "rxjs";
+
+import { ServerConfig } from "./server-config";
+
+export abstract class ConfigServiceAbstraction {
+ serverConfig$: Observable;
+}
diff --git a/libs/common/src/abstractions/config/server-config.ts b/libs/common/src/abstractions/config/server-config.ts
new file mode 100644
index 0000000000..c6c008a8cc
--- /dev/null
+++ b/libs/common/src/abstractions/config/server-config.ts
@@ -0,0 +1,40 @@
+import {
+ ServerConfigData,
+ ThirdPartyServerConfigData,
+ EnvironmentServerConfigData,
+} from "@bitwarden/common/models/data/server-config.data";
+
+const dayInMilliseconds = 24 * 3600 * 1000;
+const eighteenHoursInMilliseconds = 18 * 3600 * 1000;
+
+export class ServerConfig {
+ version: string;
+ gitHash: string;
+ server?: ThirdPartyServerConfigData;
+ environment?: EnvironmentServerConfigData;
+ utcDate: Date;
+
+ constructor(serverConfigData: ServerConfigData) {
+ this.version = serverConfigData.version;
+ this.gitHash = serverConfigData.gitHash;
+ this.server = serverConfigData.server;
+ this.utcDate = new Date(serverConfigData.utcDate);
+ this.environment = serverConfigData.environment;
+
+ if (this.server?.name == null && this.server?.url == null) {
+ this.server = null;
+ }
+ }
+
+ private getAgeInMilliseconds(): number {
+ return new Date().getTime() - this.utcDate?.getTime();
+ }
+
+ isValid(): boolean {
+ return this.getAgeInMilliseconds() <= dayInMilliseconds;
+ }
+
+ expiresSoon(): boolean {
+ return this.getAgeInMilliseconds() >= eighteenHoursInMilliseconds;
+ }
+}
diff --git a/libs/common/src/abstractions/environment.service.ts b/libs/common/src/abstractions/environment.service.ts
index 84bceaa197..1d608ed02c 100644
--- a/libs/common/src/abstractions/environment.service.ts
+++ b/libs/common/src/abstractions/environment.service.ts
@@ -33,4 +33,5 @@ export abstract class EnvironmentService {
setUrlsFromStorage: () => Promise;
setUrls: (urls: Urls) => Promise;
getUrls: () => Urls;
+ isCloud: () => boolean;
}
diff --git a/libs/common/src/abstractions/state.service.ts b/libs/common/src/abstractions/state.service.ts
index b976a6195a..238dfe199d 100644
--- a/libs/common/src/abstractions/state.service.ts
+++ b/libs/common/src/abstractions/state.service.ts
@@ -13,6 +13,7 @@ import { OrganizationData } from "../models/data/organizationData";
import { PolicyData } from "../models/data/policyData";
import { ProviderData } from "../models/data/providerData";
import { SendData } from "../models/data/sendData";
+import { ServerConfigData } from "../models/data/server-config.data";
import { Account, AccountSettingsSettings } from "../models/domain/account";
import { EncString } from "../models/domain/encString";
import { EnvironmentUrls } from "../models/domain/environmentUrls";
@@ -319,4 +320,12 @@ export abstract class StateService {
setStateVersion: (value: number) => Promise;
getWindow: () => Promise;
setWindow: (value: WindowState) => Promise;
+ /**
+ * @deprecated Do not call this directly, use ConfigService
+ */
+ getServerConfig: (options?: StorageOptions) => Promise;
+ /**
+ * @deprecated Do not call this directly, use ConfigService
+ */
+ setServerConfig: (value: ServerConfigData, options?: StorageOptions) => Promise;
}
diff --git a/libs/common/src/models/data/server-config.data.ts b/libs/common/src/models/data/server-config.data.ts
new file mode 100644
index 0000000000..75ffe17c1b
--- /dev/null
+++ b/libs/common/src/models/data/server-config.data.ts
@@ -0,0 +1,53 @@
+import {
+ ServerConfigResponse,
+ ThirdPartyServerConfigResponse,
+ EnvironmentServerConfigResponse,
+} from "../response/server-config-response";
+
+export class ServerConfigData {
+ version: string;
+ gitHash: string;
+ server?: ThirdPartyServerConfigData;
+ environment?: EnvironmentServerConfigData;
+ utcDate: string;
+
+ constructor(serverConfigReponse: ServerConfigResponse) {
+ this.version = serverConfigReponse?.version;
+ this.gitHash = serverConfigReponse?.gitHash;
+ this.server = serverConfigReponse?.server
+ ? new ThirdPartyServerConfigData(serverConfigReponse.server)
+ : null;
+ this.utcDate = new Date().toISOString();
+ this.environment = serverConfigReponse?.environment
+ ? new EnvironmentServerConfigData(serverConfigReponse.environment)
+ : null;
+ }
+}
+
+export class ThirdPartyServerConfigData {
+ name: string;
+ url: string;
+
+ constructor(response: ThirdPartyServerConfigResponse) {
+ this.name = response.name;
+ this.url = response.url;
+ }
+}
+
+export class EnvironmentServerConfigData {
+ vault: string;
+ api: string;
+ identity: string;
+ admin: string;
+ notifications: string;
+ sso: string;
+
+ constructor(response: EnvironmentServerConfigResponse) {
+ this.vault = response.vault;
+ this.api = response.api;
+ this.identity = response.identity;
+ this.admin = response.admin;
+ this.notifications = response.notifications;
+ this.sso = response.sso;
+ }
+}
diff --git a/libs/common/src/models/domain/account.ts b/libs/common/src/models/domain/account.ts
index dfeacce120..b7bbcb431c 100644
--- a/libs/common/src/models/domain/account.ts
+++ b/libs/common/src/models/domain/account.ts
@@ -10,6 +10,7 @@ import { OrganizationData } from "../data/organizationData";
import { PolicyData } from "../data/policyData";
import { ProviderData } from "../data/providerData";
import { SendData } from "../data/sendData";
+import { ServerConfigData } from "../data/server-config.data";
import { CipherView } from "../view/cipherView";
import { CollectionView } from "../view/collectionView";
import { SendView } from "../view/sendView";
@@ -140,6 +141,7 @@ export class AccountSettings {
settings?: AccountSettingsSettings; // TODO: Merge whatever is going on here into the AccountSettings model properly
vaultTimeout?: number;
vaultTimeoutAction?: string = "lock";
+ serverConfig?: ServerConfigData;
}
export type AccountSettingsSettings = {
diff --git a/libs/common/src/models/response/server-config-response.ts b/libs/common/src/models/response/server-config-response.ts
new file mode 100644
index 0000000000..79a23d6c8c
--- /dev/null
+++ b/libs/common/src/models/response/server-config-response.ts
@@ -0,0 +1,61 @@
+import { BaseResponse } from "./baseResponse";
+
+export class ServerConfigResponse extends BaseResponse {
+ version: string;
+ gitHash: string;
+ server: ThirdPartyServerConfigResponse;
+ environment: EnvironmentServerConfigResponse;
+
+ constructor(response: any) {
+ super(response);
+
+ if (response == null) {
+ return;
+ }
+
+ this.version = this.getResponseProperty("Version");
+ this.gitHash = this.getResponseProperty("GitHash");
+ this.server = new ThirdPartyServerConfigResponse(this.getResponseProperty("Server"));
+ this.environment = new EnvironmentServerConfigResponse(this.getResponseProperty("Environment"));
+ }
+}
+
+export class EnvironmentServerConfigResponse extends BaseResponse {
+ vault: string;
+ api: string;
+ identity: string;
+ admin: string;
+ notifications: string;
+ sso: string;
+
+ constructor(data: any = null) {
+ super(data);
+
+ if (data == null) {
+ return;
+ }
+
+ this.vault = this.getResponseProperty("Vault");
+ this.api = this.getResponseProperty("Api");
+ this.identity = this.getResponseProperty("Identity");
+ this.admin = this.getResponseProperty("Admin");
+ this.notifications = this.getResponseProperty("Notifications");
+ this.sso = this.getResponseProperty("Sso");
+ }
+}
+
+export class ThirdPartyServerConfigResponse extends BaseResponse {
+ name: string;
+ url: string;
+
+ constructor(data: any = null) {
+ super(data);
+
+ if (data == null) {
+ return;
+ }
+
+ this.name = this.getResponseProperty("Name");
+ this.url = this.getResponseProperty("Url");
+ }
+}
diff --git a/libs/common/src/services/config/config-api.service.ts b/libs/common/src/services/config/config-api.service.ts
new file mode 100644
index 0000000000..6b92ae0dc6
--- /dev/null
+++ b/libs/common/src/services/config/config-api.service.ts
@@ -0,0 +1,12 @@
+import { ApiService } from "@bitwarden/common/abstractions/api.service";
+import { ConfigApiServiceAbstraction as ConfigApiServiceAbstraction } from "@bitwarden/common/abstractions/config/config-api.service.abstraction";
+import { ServerConfigResponse } from "@bitwarden/common/models/response/server-config-response";
+
+export class ConfigApiService implements ConfigApiServiceAbstraction {
+ constructor(private apiService: ApiService) {}
+
+ async get(): Promise {
+ const r = await this.apiService.send("GET", "/config", null, true, true);
+ return new ServerConfigResponse(r);
+ }
+}
diff --git a/libs/common/src/services/config/config.service.ts b/libs/common/src/services/config/config.service.ts
new file mode 100644
index 0000000000..9f9e0938b5
--- /dev/null
+++ b/libs/common/src/services/config/config.service.ts
@@ -0,0 +1,61 @@
+import { BehaviorSubject, concatMap, map, switchMap, timer, EMPTY } from "rxjs";
+
+import { ServerConfigData } from "@bitwarden/common/models/data/server-config.data";
+
+import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
+import { ConfigServiceAbstraction } from "../../abstractions/config/config.service.abstraction";
+import { ServerConfig } from "../../abstractions/config/server-config";
+import { StateService } from "../../abstractions/state.service";
+
+export class ConfigService implements ConfigServiceAbstraction {
+ private _serverConfig = new BehaviorSubject(null);
+ serverConfig$ = this._serverConfig.asObservable();
+
+ constructor(
+ private stateService: StateService,
+ private configApiService: ConfigApiServiceAbstraction
+ ) {
+ this.stateService.activeAccountUnlocked$
+ .pipe(
+ switchMap((unlocked) => {
+ if (!unlocked) {
+ this._serverConfig.next(null);
+ return EMPTY;
+ }
+
+ // Re-fetch the server config every hour
+ return timer(0, 3600 * 1000).pipe(map(() => unlocked));
+ }),
+ concatMap(async (unlocked) => {
+ return unlocked ? await this.buildServerConfig() : null;
+ })
+ )
+ .subscribe((serverConfig) => {
+ this._serverConfig.next(serverConfig);
+ });
+ }
+
+ private async buildServerConfig(): Promise {
+ const data = await this.stateService.getServerConfig();
+ const domain = data ? new ServerConfig(data) : null;
+
+ if (domain == null || !domain.isValid() || domain.expiresSoon()) {
+ const value = await this.fetchServerConfig();
+ return value ?? domain;
+ }
+
+ return domain;
+ }
+
+ private async fetchServerConfig(): Promise {
+ const response = await this.configApiService.get();
+ const data = new ServerConfigData(response);
+
+ if (data != null) {
+ await this.stateService.setServerConfig(data);
+ return new ServerConfig(data);
+ }
+
+ return null;
+ }
+}
diff --git a/libs/common/src/services/environment.service.ts b/libs/common/src/services/environment.service.ts
index d79b2b189d..48561a9909 100644
--- a/libs/common/src/services/environment.service.ts
+++ b/libs/common/src/services/environment.service.ts
@@ -207,4 +207,10 @@ export class EnvironmentService implements EnvironmentServiceAbstraction {
return url.trim();
}
+
+ isCloud(): boolean {
+ return ["https://api.bitwarden.com", "https://vault.bitwarden.com/api"].includes(
+ this.getApiUrl()
+ );
+ }
}
diff --git a/libs/common/src/services/state.service.ts b/libs/common/src/services/state.service.ts
index 6dd6187cc9..d5aabb3490 100644
--- a/libs/common/src/services/state.service.ts
+++ b/libs/common/src/services/state.service.ts
@@ -21,6 +21,7 @@ import { OrganizationData } from "../models/data/organizationData";
import { PolicyData } from "../models/data/policyData";
import { ProviderData } from "../models/data/providerData";
import { SendData } from "../models/data/sendData";
+import { ServerConfigData } from "../models/data/server-config.data";
import {
Account,
AccountData,
@@ -2277,6 +2278,23 @@ export class StateService<
);
}
+ async setServerConfig(value: ServerConfigData, options?: StorageOptions): Promise {
+ const account = await this.getAccount(
+ this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
+ );
+ account.settings.serverConfig = value;
+ return await this.saveAccount(
+ account,
+ this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
+ );
+ }
+
+ async getServerConfig(options: StorageOptions): Promise {
+ return (
+ await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
+ )?.settings?.serverConfig;
+ }
+
protected async getGlobals(options: StorageOptions): Promise {
let globals: TGlobalState;
if (this.useMemory(options.storageLocation)) {