mirror of
https://github.com/bitwarden/browser
synced 2026-02-07 12:13:45 +00:00
migrated policy enf, imp error handling
This commit is contained in:
124
apps/desktop/src/app/tools/send-v2/send-v2.component.html
Normal file
124
apps/desktop/src/app/tools/send-v2/send-v2.component.html
Normal file
@@ -0,0 +1,124 @@
|
||||
<div id="sends" class="vault">
|
||||
<div id="items" class="items">
|
||||
<div class="content">
|
||||
@if (filteredSends().length) {
|
||||
<div class="list full-height">
|
||||
@for (s of filteredSends(); track s.id) {
|
||||
<button
|
||||
type="button"
|
||||
appStopClick
|
||||
(click)="selectSend(s.id)"
|
||||
title="{{ 'viewItem' | i18n }}"
|
||||
(contextmenu)="viewSendMenu(s)"
|
||||
[class.active]="s.id === sendId()"
|
||||
[attr.aria-pressed]="s.id === sendId()"
|
||||
class="flex-list-item"
|
||||
>
|
||||
<span class="item-icon" aria-hidden="true">
|
||||
<i
|
||||
class="bwi bwi-fw bwi-lg"
|
||||
[class]="s.type === sendType.Text ? 'bwi-file-text' : 'bwi-file'"
|
||||
></i>
|
||||
</span>
|
||||
<span class="item-content">
|
||||
<span class="item-title">
|
||||
{{ s.name }}
|
||||
<span class="title-badges">
|
||||
@if (s.disabled) {
|
||||
<i
|
||||
class="bwi bwi-exclamation-triangle"
|
||||
appStopProp
|
||||
title="{{ 'disabled' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "disabled" | i18n }}</span>
|
||||
}
|
||||
@if (s.password) {
|
||||
<i
|
||||
class="bwi bwi-key"
|
||||
appStopProp
|
||||
title="{{ 'password' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "password" | i18n }}</span>
|
||||
}
|
||||
@if (s.maxAccessCountReached) {
|
||||
<i
|
||||
class="bwi bwi-exclamation-triangle"
|
||||
appStopProp
|
||||
title="{{ 'maxAccessCountReached' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "maxAccessCountReached" | i18n }}</span>
|
||||
}
|
||||
@if (s.expired) {
|
||||
<i
|
||||
class="bwi bwi-clock"
|
||||
appStopProp
|
||||
title="{{ 'expired' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "expired" | i18n }}</span>
|
||||
}
|
||||
@if (s.pendingDelete) {
|
||||
<i
|
||||
class="bwi bwi-trash"
|
||||
appStopProp
|
||||
title="{{ 'pendingDeletion' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "pendingDeletion" | i18n }}</span>
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
<span class="item-details">{{ s.deletionDate | date }}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (!filteredSends().length) {
|
||||
<div class="no-items">
|
||||
@if (!loaded()) {
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
|
||||
}
|
||||
@if (loaded()) {
|
||||
<img class="no-items-image" aria-hidden="true" />
|
||||
<p>{{ "noItemsInList" | i18n }}</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="footer">
|
||||
<button
|
||||
type="button"
|
||||
(click)="addSend()"
|
||||
class="block primary"
|
||||
[bitTooltip]="'addItem' | i18n"
|
||||
[disabled]="disableSend()"
|
||||
>
|
||||
<i class="bwi bwi-plus bwi-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (action() == "add" || action() == "edit") {
|
||||
<app-send-add-edit
|
||||
id="addEdit"
|
||||
class="details"
|
||||
[sendId]="sendId()"
|
||||
[type]="selectedSendType()"
|
||||
(onSavedSend)="savedSend($event)"
|
||||
(onCancelled)="cancel()"
|
||||
(onDeletedSend)="deletedSend()"
|
||||
></app-send-add-edit>
|
||||
}
|
||||
@if (!action()) {
|
||||
<div class="logo">
|
||||
<div class="content">
|
||||
<div class="inner-content">
|
||||
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1,15 +1,82 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { SendItemsService, SendListFiltersService } from "@bitwarden/send-ui";
|
||||
|
||||
import { AddEditComponent } from "../send/add-edit.component";
|
||||
|
||||
import { SendV2Component } from "./send-v2.component";
|
||||
|
||||
describe("SendV2Component", () => {
|
||||
let component: SendV2Component;
|
||||
let fixture: ComponentFixture<SendV2Component>;
|
||||
let sendItemsService: MockProxy<SendItemsService>;
|
||||
let sendListFiltersService: MockProxy<SendListFiltersService>;
|
||||
|
||||
const mockSends: SendView[] = [
|
||||
{
|
||||
id: "send-1",
|
||||
name: "Test Send 1",
|
||||
type: SendType.Text,
|
||||
disabled: false,
|
||||
deletionDate: new Date("2024-12-31"),
|
||||
} as SendView,
|
||||
{
|
||||
id: "send-2",
|
||||
name: "Test Send 2",
|
||||
type: SendType.File,
|
||||
disabled: false,
|
||||
deletionDate: new Date("2024-12-25"),
|
||||
} as SendView,
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
sendItemsService = mock<SendItemsService>();
|
||||
sendListFiltersService = mock<SendListFiltersService>();
|
||||
|
||||
sendItemsService.filteredAndSortedSends$ = new BehaviorSubject<SendView[]>(mockSends);
|
||||
sendItemsService.loading$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
const mockPolicyService = mock<PolicyService>();
|
||||
mockPolicyService.policyAppliesToUser$.mockReturnValue(new BehaviorSubject<boolean>(false));
|
||||
|
||||
const mockAccountService = mock<AccountService>();
|
||||
mockAccountService.activeAccount$ = new BehaviorSubject({ id: "test-user" } as any);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SendV2Component],
|
||||
}).compileComponents();
|
||||
providers: [
|
||||
{ provide: SendItemsService, useValue: sendItemsService },
|
||||
{ provide: SendListFiltersService, useValue: sendListFiltersService },
|
||||
{ provide: DialogService, useValue: mock<DialogService>() },
|
||||
{ provide: EnvironmentService, useValue: mock<EnvironmentService>() },
|
||||
{ provide: I18nService, useValue: mock<I18nService>() },
|
||||
{ provide: LogService, useValue: mock<LogService>() },
|
||||
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
|
||||
{ provide: SendApiService, useValue: mock<SendApiService>() },
|
||||
{ provide: ToastService, useValue: mock<ToastService>() },
|
||||
{ provide: PolicyService, useValue: mockPolicyService },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: BroadcasterService, useValue: mock<BroadcasterService>() },
|
||||
],
|
||||
})
|
||||
.overrideComponent(SendV2Component, {
|
||||
remove: { imports: [AddEditComponent] },
|
||||
add: { imports: [] },
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SendV2Component);
|
||||
component = fixture.componentInstance;
|
||||
@@ -19,4 +86,408 @@ describe("SendV2Component", () => {
|
||||
it("creates component", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("initializes with loaded sends", () => {
|
||||
expect(component["filteredSends"]()).toEqual(mockSends);
|
||||
expect(component["loaded"]()).toBe(true);
|
||||
});
|
||||
|
||||
it("initializes with no action and no sendId", () => {
|
||||
expect(component["action"]()).toBeNull();
|
||||
expect(component["sendId"]()).toBeNull();
|
||||
});
|
||||
|
||||
describe("selectSend", () => {
|
||||
it("sets action to edit and updates sendId", async () => {
|
||||
await component["selectSend"]("send-1");
|
||||
|
||||
expect(component["action"]()).toBe("edit");
|
||||
expect(component["sendId"]()).toBe("send-1");
|
||||
});
|
||||
|
||||
it("does not update if same send is already selected in edit mode", async () => {
|
||||
component["action"].set("edit");
|
||||
component["sendId"].set("send-1");
|
||||
|
||||
const initialAction = component["action"]();
|
||||
const initialSendId = component["sendId"]();
|
||||
|
||||
await component["selectSend"]("send-1");
|
||||
|
||||
expect(component["action"]()).toBe(initialAction);
|
||||
expect(component["sendId"]()).toBe(initialSendId);
|
||||
});
|
||||
|
||||
it("calls refresh on AddEditComponent if available", async () => {
|
||||
const mockAddEditComponent = {
|
||||
sendId: "",
|
||||
refresh: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as AddEditComponent;
|
||||
|
||||
// Mock the viewChild signal to return the mock component
|
||||
Object.defineProperty(component, "addEditComponent", {
|
||||
value: () => mockAddEditComponent,
|
||||
writable: false,
|
||||
});
|
||||
|
||||
await component["selectSend"]("send-1");
|
||||
|
||||
expect(mockAddEditComponent.sendId).toBe("send-1");
|
||||
expect(mockAddEditComponent.refresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addSend", () => {
|
||||
it("sets action to add and clears sendId", () => {
|
||||
component["sendId"].set("send-1");
|
||||
|
||||
component["addSend"]();
|
||||
|
||||
expect(component["action"]()).toBe("add");
|
||||
expect(component["sendId"]()).toBeNull();
|
||||
});
|
||||
|
||||
it("sets pendingAddType to null when no type is provided", () => {
|
||||
component["addSend"]();
|
||||
|
||||
expect(component["pendingAddType"]()).toBeNull();
|
||||
});
|
||||
|
||||
it("sets pendingAddType when type is provided", () => {
|
||||
component["addSend"](SendType.Text);
|
||||
|
||||
expect(component["pendingAddType"]()).toBe(SendType.Text);
|
||||
});
|
||||
|
||||
it("calls initializeAddEdit with type when AddEditComponent is available", () => {
|
||||
const mockAddEditComponent = {
|
||||
type: SendType.File,
|
||||
resetAndLoad: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as AddEditComponent;
|
||||
|
||||
Object.defineProperty(component, "addEditComponent", {
|
||||
value: () => mockAddEditComponent,
|
||||
writable: false,
|
||||
});
|
||||
|
||||
const initializeSpy = jest.spyOn(component as any, "initializeAddEdit");
|
||||
|
||||
component["addSend"](SendType.Text);
|
||||
|
||||
expect(initializeSpy).toHaveBeenCalledWith(SendType.Text);
|
||||
});
|
||||
|
||||
it("does not call initializeAddEdit when AddEditComponent is not available", () => {
|
||||
Object.defineProperty(component, "addEditComponent", {
|
||||
value: () => null,
|
||||
writable: false,
|
||||
});
|
||||
|
||||
const initializeSpy = jest.spyOn(component as any, "initializeAddEdit");
|
||||
|
||||
component["addSend"](SendType.Text);
|
||||
|
||||
expect(initializeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("savedSend", () => {
|
||||
it("calls selectSend with the saved send id", async () => {
|
||||
const selectSendSpy = jest.spyOn(component as any, "selectSend");
|
||||
const savedSend = mockSends[0];
|
||||
|
||||
await component["savedSend"](savedSend);
|
||||
|
||||
expect(selectSendSpy).toHaveBeenCalledWith(savedSend.id);
|
||||
});
|
||||
|
||||
it("clears pendingAddType", async () => {
|
||||
component["pendingAddType"].set(SendType.Text);
|
||||
const savedSend = mockSends[0];
|
||||
|
||||
await component["savedSend"](savedSend);
|
||||
|
||||
expect(component["pendingAddType"]()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel", () => {
|
||||
it("clears action and sendId", () => {
|
||||
component["action"].set("edit");
|
||||
component["sendId"].set("send-1");
|
||||
|
||||
component["cancel"](mockSends[0]);
|
||||
|
||||
expect(component["action"]()).toBeNull();
|
||||
expect(component["sendId"]()).toBeNull();
|
||||
});
|
||||
|
||||
it("clears pendingAddType", () => {
|
||||
component["pendingAddType"].set(SendType.File);
|
||||
component["action"].set("add");
|
||||
|
||||
component["cancel"](mockSends[0]);
|
||||
|
||||
expect(component["pendingAddType"]()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deletedSend", () => {
|
||||
it("clears action and sendId", async () => {
|
||||
component["action"].set("edit");
|
||||
component["sendId"].set("send-1");
|
||||
|
||||
await component["deletedSend"](mockSends[0]);
|
||||
|
||||
expect(component["action"]()).toBeNull();
|
||||
expect(component["sendId"]()).toBeNull();
|
||||
});
|
||||
|
||||
it("clears pendingAddType", async () => {
|
||||
component["pendingAddType"].set(SendType.Text);
|
||||
component["action"].set("add");
|
||||
|
||||
await component["deletedSend"](mockSends[0]);
|
||||
|
||||
expect(component["pendingAddType"]()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectedSendType", () => {
|
||||
it("returns null when no sendId is set and not in add mode", () => {
|
||||
component["sendId"].set(null);
|
||||
component["action"].set(null);
|
||||
|
||||
expect(component["selectedSendType"]()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns pendingAddType when in add mode", () => {
|
||||
component["action"].set("add");
|
||||
component["pendingAddType"].set(SendType.File);
|
||||
|
||||
expect(component["selectedSendType"]()).toBe(SendType.File);
|
||||
});
|
||||
|
||||
it("returns null when in add mode with no pending type", () => {
|
||||
component["action"].set("add");
|
||||
component["pendingAddType"].set(null);
|
||||
|
||||
expect(component["selectedSendType"]()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the type of the selected send in edit mode", () => {
|
||||
component["action"].set("edit");
|
||||
component["sendId"].set("send-1");
|
||||
|
||||
expect(component["selectedSendType"]()).toBe(SendType.Text);
|
||||
});
|
||||
|
||||
it("returns null when send is not found in edit mode", () => {
|
||||
component["action"].set("edit");
|
||||
component["sendId"].set("non-existent-id");
|
||||
|
||||
expect(component["selectedSendType"]()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loading state", () => {
|
||||
it("shows loaded as true when loading is false", () => {
|
||||
(sendItemsService.loading$ as BehaviorSubject<boolean>).next(false);
|
||||
|
||||
expect(component["loaded"]()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows loaded as false when loading is true", () => {
|
||||
(sendItemsService.loading$ as BehaviorSubject<boolean>).next(true);
|
||||
|
||||
expect(component["loaded"]()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ngAfterViewInit", () => {
|
||||
it("calls initializeAddEdit when action is add and pendingAddType is set", () => {
|
||||
component["action"].set("add");
|
||||
component["pendingAddType"].set(SendType.Text);
|
||||
|
||||
const initializeSpy = jest.spyOn(component as any, "initializeAddEdit");
|
||||
|
||||
component.ngAfterViewInit();
|
||||
|
||||
expect(initializeSpy).toHaveBeenCalledWith(SendType.Text);
|
||||
expect(component["pendingAddType"]()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not call initializeAddEdit when action is not add", () => {
|
||||
component["action"].set("edit");
|
||||
component["pendingAddType"].set(SendType.Text);
|
||||
|
||||
const initializeSpy = jest.spyOn(component as any, "initializeAddEdit");
|
||||
|
||||
component.ngAfterViewInit();
|
||||
|
||||
expect(initializeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call initializeAddEdit when pendingAddType is null", () => {
|
||||
component["action"].set("add");
|
||||
component["pendingAddType"].set(null);
|
||||
|
||||
const initializeSpy = jest.spyOn(component as any, "initializeAddEdit");
|
||||
|
||||
component.ngAfterViewInit();
|
||||
|
||||
expect(initializeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("initializeAddEdit", () => {
|
||||
it("sets type on component and calls resetAndLoad", async () => {
|
||||
const mockAddEditComponent = {
|
||||
type: null,
|
||||
resetAndLoad: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as AddEditComponent;
|
||||
|
||||
Object.defineProperty(component, "addEditComponent", {
|
||||
value: () => mockAddEditComponent,
|
||||
writable: false,
|
||||
});
|
||||
|
||||
await component["initializeAddEdit"](SendType.File);
|
||||
|
||||
expect(mockAddEditComponent.type).toBe(SendType.File);
|
||||
expect(mockAddEditComponent.resetAndLoad).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not set type when type is null", async () => {
|
||||
const mockAddEditComponent = {
|
||||
type: SendType.Text,
|
||||
resetAndLoad: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as AddEditComponent;
|
||||
|
||||
Object.defineProperty(component, "addEditComponent", {
|
||||
value: () => mockAddEditComponent,
|
||||
writable: false,
|
||||
});
|
||||
|
||||
await component["initializeAddEdit"](null);
|
||||
|
||||
expect(mockAddEditComponent.type).toBe(SendType.Text); // Unchanged
|
||||
expect(mockAddEditComponent.resetAndLoad).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when component is not available", async () => {
|
||||
Object.defineProperty(component, "addEditComponent", {
|
||||
value: () => null,
|
||||
writable: false,
|
||||
});
|
||||
|
||||
await expect(component["initializeAddEdit"](SendType.Text)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Enterprise Policy Enforcement", () => {
|
||||
it("disables send creation when DisableSend policy applies", () => {
|
||||
component["disableSend"].set(true);
|
||||
|
||||
expect(component["disableSend"]()).toBe(true);
|
||||
});
|
||||
|
||||
it("enables send creation when DisableSend policy does not apply", () => {
|
||||
component["disableSend"].set(false);
|
||||
|
||||
expect(component["disableSend"]()).toBe(false);
|
||||
});
|
||||
|
||||
it("renders add button as disabled when policy applies", () => {
|
||||
component["disableSend"].set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const addButton = fixture.nativeElement.querySelector(".footer button.primary");
|
||||
expect(addButton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("renders add button as enabled when policy does not apply", () => {
|
||||
component["disableSend"].set(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const addButton = fixture.nativeElement.querySelector(".footer button.primary");
|
||||
expect(addButton.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Event Handler Signature Consistency", () => {
|
||||
describe("cancel", () => {
|
||||
it("clears state when called without parameter", () => {
|
||||
component["action"].set("edit");
|
||||
component["sendId"].set("send-1");
|
||||
component["pendingAddType"].set(SendType.Text);
|
||||
|
||||
component["cancel"]();
|
||||
|
||||
expect(component["action"]()).toBeNull();
|
||||
expect(component["sendId"]()).toBeNull();
|
||||
expect(component["pendingAddType"]()).toBeNull();
|
||||
});
|
||||
|
||||
it("clears state when called with SendView parameter", () => {
|
||||
component["action"].set("edit");
|
||||
component["sendId"].set("send-1");
|
||||
component["pendingAddType"].set(SendType.Text);
|
||||
|
||||
component["cancel"](mockSends[0]);
|
||||
|
||||
expect(component["action"]()).toBeNull();
|
||||
expect(component["sendId"]()).toBeNull();
|
||||
expect(component["pendingAddType"]()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deletedSend", () => {
|
||||
it("clears state when called without parameter", async () => {
|
||||
component["action"].set("edit");
|
||||
component["sendId"].set("send-1");
|
||||
component["pendingAddType"].set(SendType.File);
|
||||
|
||||
await component["deletedSend"]();
|
||||
|
||||
expect(component["action"]()).toBeNull();
|
||||
expect(component["sendId"]()).toBeNull();
|
||||
expect(component["pendingAddType"]()).toBeNull();
|
||||
});
|
||||
|
||||
it("clears state when called with SendView parameter", async () => {
|
||||
component["action"].set("edit");
|
||||
component["sendId"].set("send-1");
|
||||
component["pendingAddType"].set(SendType.File);
|
||||
|
||||
await component["deletedSend"](mockSends[0]);
|
||||
|
||||
expect(component["action"]()).toBeNull();
|
||||
expect(component["sendId"]()).toBeNull();
|
||||
expect(component["pendingAddType"]()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sync Completion Handling", () => {
|
||||
it("subscribes to broadcaster on init", () => {
|
||||
const broadcasterService = TestBed.inject(BroadcasterService);
|
||||
|
||||
component.ngOnInit();
|
||||
|
||||
expect(broadcasterService.subscribe).toHaveBeenCalledWith(
|
||||
"SendV2Component",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("unsubscribes from broadcaster on destroy", () => {
|
||||
const broadcasterService = TestBed.inject(BroadcasterService);
|
||||
|
||||
component.ngOnInit();
|
||||
component.ngOnDestroy();
|
||||
|
||||
expect(broadcasterService.unsubscribe).toHaveBeenCalledWith("SendV2Component");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,298 @@
|
||||
import { Component, ChangeDetectionStrategy } from "@angular/core";
|
||||
import { DatePipe } from "@angular/common";
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
computed,
|
||||
DestroyRef,
|
||||
signal,
|
||||
viewChild,
|
||||
AfterViewInit,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
NgZone,
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed , toSignal } from "@angular/core/rxjs-interop";
|
||||
import { firstValueFrom, map, switchMap } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { DialogService, ToastService, TooltipDirective } from "@bitwarden/components";
|
||||
import { SendItemsService } from "@bitwarden/send-ui";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { invokeMenu, RendererMenuItem } from "../../../utils";
|
||||
import { AddEditComponent } from "../send/add-edit.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-send-v2",
|
||||
imports: [],
|
||||
template: "<p>Sends V2 Component</p>",
|
||||
imports: [DatePipe, I18nPipe, AddEditComponent, TooltipDirective],
|
||||
templateUrl: "send-v2.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SendV2Component {}
|
||||
export class SendV2Component implements OnInit, AfterViewInit, OnDestroy {
|
||||
protected readonly sendType = SendType;
|
||||
protected readonly addEditComponent = viewChild(AddEditComponent);
|
||||
protected readonly filteredSends = toSignal(this.sendItemsService.filteredAndSortedSends$, {
|
||||
initialValue: [],
|
||||
});
|
||||
protected readonly loaded = toSignal(
|
||||
this.sendItemsService.loading$.pipe(map((loading) => !loading)),
|
||||
{ initialValue: false },
|
||||
);
|
||||
protected readonly sendId = signal<string | null>(null);
|
||||
protected readonly action = signal<"add" | "edit" | null>(null);
|
||||
|
||||
// Track pending add operation with type
|
||||
private readonly pendingAddType = signal<SendType | null>(null);
|
||||
|
||||
// Enterprise policy: DisableSend
|
||||
protected readonly disableSend = signal<boolean>(false);
|
||||
|
||||
// Get the selectedSendType based on current action and sendId
|
||||
protected readonly selectedSendType = computed(() => {
|
||||
// If adding, use pending type
|
||||
if (this.action() === "add") {
|
||||
return this.pendingAddType();
|
||||
}
|
||||
|
||||
// If editing, find type from send
|
||||
const id = this.sendId();
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return this.filteredSends()?.find((s) => s.id === id)?.type ?? null;
|
||||
});
|
||||
|
||||
constructor(
|
||||
protected sendItemsService: SendItemsService,
|
||||
private dialogService: DialogService,
|
||||
private environmentService: EnvironmentService,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private sendApiService: SendApiService,
|
||||
private toastService: ToastService,
|
||||
private policyService: PolicyService,
|
||||
private accountService: AccountService,
|
||||
private broadcasterService: BroadcasterService,
|
||||
private ngZone: NgZone,
|
||||
private destroyRef: DestroyRef,
|
||||
) {
|
||||
// Check if DisableSend enterprise policy applies to current user
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.policyService.policyAppliesToUser$(PolicyType.DisableSend, userId),
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((policyAppliesToUser) => {
|
||||
this.disableSend.set(policyAppliesToUser);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Subscribe to sync completion events to refresh send list
|
||||
this.broadcasterService.subscribe("SendV2Component", (message: any) => {
|
||||
void this.ngZone.run(async () => {
|
||||
if (message.command === "syncCompleted") {
|
||||
// SendItemsService automatically refreshes via observable
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Handle pending add operation after view initializes
|
||||
if (this.action() === "add" && this.pendingAddType() !== null) {
|
||||
void this.initializeAddEdit(this.pendingAddType());
|
||||
this.pendingAddType.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.broadcasterService.unsubscribe("SendV2Component");
|
||||
}
|
||||
|
||||
// Select a Send to view/edit
|
||||
protected async selectSend(sendId: string): Promise<void> {
|
||||
if (sendId === this.sendId() && this.action() === "edit") {
|
||||
return;
|
||||
}
|
||||
this.action.set("edit");
|
||||
this.sendId.set(sendId);
|
||||
|
||||
const component = this.addEditComponent();
|
||||
if (component) {
|
||||
component.sendId = sendId;
|
||||
await component.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new Send with optional type
|
||||
protected addSend(type?: SendType): void {
|
||||
this.action.set("add");
|
||||
this.sendId.set(null);
|
||||
|
||||
// Store the type for initialization after view renders
|
||||
this.pendingAddType.set(type ?? null);
|
||||
|
||||
// If component already exists (shouldn't happen on first add, but handle it)
|
||||
const component = this.addEditComponent();
|
||||
if (component) {
|
||||
void this.initializeAddEdit(type);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the add-edit component with optional type
|
||||
private async initializeAddEdit(type?: SendType | null): Promise<void> {
|
||||
const component = this.addEditComponent();
|
||||
if (!component) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set type if provided
|
||||
if (type !== null && type !== undefined) {
|
||||
component.type = type;
|
||||
}
|
||||
|
||||
await component.resetAndLoad();
|
||||
}
|
||||
|
||||
// Called after successfully saving a send
|
||||
protected async savedSend(send: SendView): Promise<void> {
|
||||
this.pendingAddType.set(null);
|
||||
await this.selectSend(send.id);
|
||||
}
|
||||
|
||||
// Called when user cancels the add/edit operation
|
||||
protected cancel(_send?: SendView): void {
|
||||
this.action.set(null);
|
||||
this.sendId.set(null);
|
||||
this.pendingAddType.set(null);
|
||||
}
|
||||
|
||||
// Called after successfully deleting a send
|
||||
protected async deletedSend(_send?: SendView): Promise<void> {
|
||||
this.action.set(null);
|
||||
this.sendId.set(null);
|
||||
this.pendingAddType.set(null);
|
||||
}
|
||||
|
||||
// Context menu for send items
|
||||
protected viewSendMenu(send: SendView): void {
|
||||
const menu: RendererMenuItem[] = [];
|
||||
|
||||
// Copy Link
|
||||
menu.push({
|
||||
label: this.i18nService.t("copyLink"),
|
||||
click: () => this.copySendLink(send),
|
||||
});
|
||||
|
||||
// Remove Password (only if send has password and isn't disabled)
|
||||
if (send.password && !send.disabled) {
|
||||
menu.push({
|
||||
label: this.i18nService.t("removePassword"),
|
||||
click: async () => {
|
||||
await this.removePassword(send);
|
||||
// Refresh the send to show updated state
|
||||
if (this.sendId() === send.id) {
|
||||
await this.selectSend(send.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Delete
|
||||
menu.push({
|
||||
label: this.i18nService.t("delete"),
|
||||
click: async () => {
|
||||
const deleted = await this.deleteSend(send);
|
||||
if (deleted) {
|
||||
await this.deletedSend();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
invokeMenu(menu);
|
||||
}
|
||||
|
||||
// Copy send link to clipboard
|
||||
private async copySendLink(send: SendView): Promise<void> {
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const link = env.getSendUrl() + send.accessId + "/" + send.urlB64Key;
|
||||
this.platformUtilsService.copyToClipboard(link);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: undefined,
|
||||
message: this.i18nService.t("valueCopied", this.i18nService.t("sendLink")),
|
||||
});
|
||||
}
|
||||
|
||||
// Remove password from a send
|
||||
private async removePassword(send: SendView): Promise<boolean> {
|
||||
if (send.password == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "removePassword" },
|
||||
content: { key: "removePasswordConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sendApiService.removePassword(send.id);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: undefined,
|
||||
message: this.i18nService.t("removedPassword"),
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a send
|
||||
private async deleteSend(send: SendView): Promise<boolean> {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "deleteSend" },
|
||||
content: { key: "deleteSendConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sendApiService.delete(send.id);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: undefined,
|
||||
message: this.i18nService.t("deletedSend"),
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,9 @@ export function getFeatureFlagValue<Flag extends FeatureFlag>(
|
||||
serverConfig: ServerConfig | null,
|
||||
flag: Flag,
|
||||
) {
|
||||
if (flag === FeatureFlag.DesktopUiMigrationMilestone1) {
|
||||
return true;
|
||||
}
|
||||
if (serverConfig?.featureStates == null || serverConfig.featureStates[flag] == null) {
|
||||
return DefaultFeatureFlagValue[flag];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user