();
+ /** Event handler for when an algorithm is selected */
+ onAlgorithmSelected = (selected: AlgorithmInfo) => {
+ this.algorithmSelected.emit(selected);
+ };
+
/** Event handler for both generation components */
onCredentialGenerated = (generatedCred: GeneratedCredential) => {
this.valueGenerated.emit(generatedCred.credential);
diff --git a/libs/vault/src/cipher-view/cipher-view.component.html b/libs/vault/src/cipher-view/cipher-view.component.html
index f0ebeecdf40..def98b2fe96 100644
--- a/libs/vault/src/cipher-view/cipher-view.component.html
+++ b/libs/vault/src/cipher-view/cipher-view.component.html
@@ -3,6 +3,19 @@
{{ "cardExpiredMessage" | i18n }}
+
+ 0 && hadPendingChangePasswordTask"
+ type="warning"
+ [title]="''"
+ >
+
+
+ {{ "changeAtRiskPassword" | i18n }}
+
+
+
+
-
+
a?.id));
+ activeUserId$ = getUserId(this.accountService.activeAccount$);
/**
* Optional list of collections the cipher is assigned to. If none are provided, they will be fetched using the
@@ -68,12 +75,18 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
folder$: Observable | undefined;
private destroyed$: Subject = new Subject();
cardIsExpired: boolean = false;
+ hadPendingChangePasswordTask: boolean = false;
+ isSecurityTasksEnabled$ = this.configService.getFeatureFlag$(FeatureFlag.SecurityTasks);
constructor(
private organizationService: OrganizationService,
private collectionService: CollectionService,
private folderService: FolderService,
private accountService: AccountService,
+ private defaultTaskService: TaskService,
+ private platformUtilsService: PlatformUtilsService,
+ private changeLoginPasswordService: ChangeLoginPasswordService,
+ private configService: ConfigService,
) {}
async ngOnChanges() {
@@ -137,7 +150,11 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
);
}
- const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
+ const userId = await firstValueFrom(this.activeUserId$);
+
+ if (this.cipher.edit && this.cipher.viewPassword) {
+ await this.checkPendingChangePasswordTasks(userId);
+ }
if (this.cipher.organizationId && userId) {
this.organization$ = this.organizationService
@@ -147,15 +164,29 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
}
if (this.cipher.folderId) {
- const activeUserId = await firstValueFrom(this.activeUserId$);
-
- if (!activeUserId) {
- return;
- }
-
this.folder$ = this.folderService
- .getDecrypted$(this.cipher.folderId, activeUserId)
+ .getDecrypted$(this.cipher.folderId, userId)
.pipe(takeUntil(this.destroyed$));
}
}
+
+ async checkPendingChangePasswordTasks(userId: UserId): Promise {
+ const tasks = await firstValueFrom(this.defaultTaskService.pendingTasks$(userId));
+
+ this.hadPendingChangePasswordTask = tasks?.some((task) => {
+ return (
+ task.cipherId === this.cipher?.id && task.type === SecurityTaskType.UpdateAtRiskCredential
+ );
+ });
+ }
+
+ launchChangePassword = async () => {
+ if (this.cipher != null) {
+ const url = await this.changeLoginPasswordService.getChangePasswordUrl(this.cipher);
+ if (url == null) {
+ return;
+ }
+ this.platformUtilsService.launchUri(url);
+ }
+ };
}
diff --git a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html
index 8503604bf7c..6de6fb6848d 100644
--- a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html
+++ b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html
@@ -89,6 +89,12 @@
(click)="logCopyEvent()"
>
+
+
+ {{ "changeAtRiskPassword" | i18n }}
+
+
+
{
{ provide: PlatformUtilsService, useValue: mock
() },
{ provide: ToastService, useValue: mock() },
{ provide: I18nService, useValue: { t: (...keys: string[]) => keys.join(" ") } },
+ { provide: ConfigService, useValue: mock() },
],
}).compileComponents();
diff --git a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts
index c95b2040fd2..27d81f32ee6 100644
--- a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts
+++ b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts
@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule, DatePipe } from "@angular/common";
-import { Component, inject, Input } from "@angular/core";
+import { Component, EventEmitter, inject, Input, Output } from "@angular/core";
import { Observable, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -10,6 +10,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { EventType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { UserId } from "@bitwarden/common/types/guid";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
@@ -17,6 +18,7 @@ import {
SectionComponent,
SectionHeaderComponent,
TypographyModule,
+ LinkModule,
IconButtonModule,
BadgeModule,
ColorPasswordModule,
@@ -46,10 +48,14 @@ type TotpCodeValues = {
ColorPasswordModule,
BitTotpCountdownComponent,
ReadOnlyCipherCardComponent,
+ LinkModule,
],
})
export class LoginCredentialsViewComponent {
@Input() cipher: CipherView;
+ @Input() activeUserId: UserId;
+ @Input() hadPendingChangePasswordTask: boolean;
+ @Output() handleChangePassword = new EventEmitter();
isPremium$: Observable = this.accountService.activeAccount$.pipe(
switchMap((account) =>
@@ -59,6 +65,7 @@ export class LoginCredentialsViewComponent {
showPasswordCount: boolean = false;
passwordRevealed: boolean = false;
totpCodeCopyObj: TotpCodeValues;
+
private datePipe = inject(DatePipe);
constructor(
@@ -111,4 +118,8 @@ export class LoginCredentialsViewComponent {
this.cipher.organizationId,
);
}
+
+ launchChangePasswordEvent(): void {
+ this.handleChangePassword.emit();
+ }
}
diff --git a/libs/vault/src/services/default-change-login-password.service.spec.ts b/libs/vault/src/services/default-change-login-password.service.spec.ts
index 4805f298797..37123604e9a 100644
--- a/libs/vault/src/services/default-change-login-password.service.spec.ts
+++ b/libs/vault/src/services/default-change-login-password.service.spec.ts
@@ -131,13 +131,13 @@ describe("DefaultChangeLoginPasswordService", () => {
const cipher = {
type: CipherType.Login,
login: Object.assign(new LoginView(), {
- uris: [{ uri: "https://example.com" }],
+ uris: [{ uri: "https://example.com/" }],
}),
} as CipherView;
const url = await service.getChangePasswordUrl(cipher);
- expect(url).toBe("https://example.com");
+ expect(url).toBe("https://example.com/");
});
it("should return the original URI when the well-known URL is not found", async () => {
@@ -146,12 +146,42 @@ describe("DefaultChangeLoginPasswordService", () => {
const cipher = {
type: CipherType.Login,
login: Object.assign(new LoginView(), {
- uris: [{ uri: "https://example.com" }],
+ uris: [{ uri: "https://example.com/" }],
}),
} as CipherView;
const url = await service.getChangePasswordUrl(cipher);
- expect(url).toBe("https://example.com");
+ expect(url).toBe("https://example.com/");
+ });
+
+ it("should try the next URI if the first one fails", async () => {
+ mockApiService.nativeFetch.mockImplementation((request) => {
+ if (
+ request.url.endsWith("resource-that-should-not-exist-whose-status-code-should-not-be-200")
+ ) {
+ return Promise.resolve(mockShouldNotExistResponse);
+ }
+
+ if (request.url.endsWith(".well-known/change-password")) {
+ if (request.url.includes("working.com")) {
+ return Promise.resolve(mockWellKnownResponse);
+ }
+ return Promise.resolve(new Response("Not Found", { status: 404 }));
+ }
+
+ throw new Error("Unexpected request");
+ });
+
+ const cipher = {
+ type: CipherType.Login,
+ login: Object.assign(new LoginView(), {
+ uris: [{ uri: "https://no-wellknown.com/" }, { uri: "https://working.com/" }],
+ }),
+ } as CipherView;
+
+ const url = await service.getChangePasswordUrl(cipher);
+
+ expect(url).toBe("https://working.com/.well-known/change-password");
});
});
diff --git a/libs/vault/src/services/default-change-login-password.service.ts b/libs/vault/src/services/default-change-login-password.service.ts
index 25648318c14..29818f95c0a 100644
--- a/libs/vault/src/services/default-change-login-password.service.ts
+++ b/libs/vault/src/services/default-change-login-password.service.ts
@@ -20,25 +20,31 @@ export class DefaultChangeLoginPasswordService implements ChangeLoginPasswordSer
return null;
}
- // Find the first valid URL that is an HTTP or HTTPS URL
- const url = cipher.login.uris
+ // Filter for valid URLs that are HTTP(S)
+ const urls = cipher.login.uris
.map((m) => Utils.getUrl(m.uri))
- .find((m) => m != null && (m.protocol === "http:" || m.protocol === "https:"));
+ .filter((m) => m != null && (m.protocol === "http:" || m.protocol === "https:"));
- if (url == null) {
+ if (urls.length === 0) {
return null;
}
- const [reliable, wellKnownChangeUrl] = await Promise.all([
- this.hasReliableHttpStatusCode(url.origin),
- this.getWellKnownChangePasswordUrl(url.origin),
- ]);
+ for (const url of urls) {
+ const [reliable, wellKnownChangeUrl] = await Promise.all([
+ this.hasReliableHttpStatusCode(url.origin),
+ this.getWellKnownChangePasswordUrl(url.origin),
+ ]);
- if (!reliable || wellKnownChangeUrl == null) {
- return cipher.login.uri;
+ // Some servers return a 200 OK for a resource that should not exist
+ // Which means we cannot trust the well-known URL is valid, so we skip it
+ // to avoid potentially sending users to a 404 page
+ if (reliable && wellKnownChangeUrl != null) {
+ return wellKnownChangeUrl;
+ }
}
- return wellKnownChangeUrl;
+ // No reliable well-known URL found, fallback to the first URL
+ return urls[0].href;
}
/**
diff --git a/package-lock.json b/package-lock.json
index ab526f2730b..142a4e13c21 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -230,7 +230,7 @@
},
"apps/desktop": {
"name": "@bitwarden/desktop",
- "version": "2025.2.2",
+ "version": "2025.2.1",
"hasInstallScript": true,
"license": "GPL-3.0"
},