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)) {