diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts
index b6e86ba19ff..fd06640737c 100644
--- a/apps/desktop/src/app/app-routing.module.ts
+++ b/apps/desktop/src/app/app-routing.module.ts
@@ -14,6 +14,7 @@ import {
} from "@bitwarden/angular/auth/guards";
import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password";
import { SetInitialPasswordComponent } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.component";
+import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
import {
DevicesIcon,
RegistrationUserAddIcon,
@@ -39,15 +40,19 @@ import {
TwoFactorAuthGuard,
NewDeviceVerificationComponent,
} from "@bitwarden/auth/angular";
+import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components";
import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui";
import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
import { VaultV2Component } from "../vault/app/vault/vault-v2.component";
+import { VaultComponent } from "../vault/app/vault-v3/vault.component";
import { Fido2PlaceholderComponent } from "./components/fido2placeholder.component";
+import { UserLayoutComponent } from "./layout/user-layout.component";
import { SendComponent } from "./tools/send/send.component";
+import { SendsComponent } from "./tools/send-v2/sends.component";
/**
* Data properties acceptable for use in route objects in the desktop
@@ -99,7 +104,10 @@ const routes: Routes = [
{
path: "vault",
component: VaultV2Component,
- canActivate: [authGuard],
+ canActivate: [
+ authGuard,
+ canAccessFeature(FeatureFlag.DesktopUiMigrationMilestone1, false, "new-vault", false),
+ ],
},
{
path: "send",
@@ -325,6 +333,21 @@ const routes: Routes = [
},
],
},
+ {
+ path: "",
+ component: UserLayoutComponent,
+ canActivate: [authGuard],
+ children: [
+ {
+ path: "new-vault",
+ component: VaultComponent,
+ },
+ {
+ path: "new-sends",
+ component: SendsComponent,
+ },
+ ],
+ },
];
@NgModule({
diff --git a/apps/desktop/src/app/layout/desktop-layout.component.html b/apps/desktop/src/app/layout/desktop-layout.component.html
new file mode 100644
index 00000000000..71a760735ca
--- /dev/null
+++ b/apps/desktop/src/app/layout/desktop-layout.component.html
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/desktop/src/app/layout/desktop-layout.component.spec.ts b/apps/desktop/src/app/layout/desktop-layout.component.spec.ts
new file mode 100644
index 00000000000..cc2f7e58dfb
--- /dev/null
+++ b/apps/desktop/src/app/layout/desktop-layout.component.spec.ts
@@ -0,0 +1,61 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { RouterModule } from "@angular/router";
+import { mock } from "jest-mock-extended";
+
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { NavigationModule } from "@bitwarden/components";
+
+import { DesktopLayoutComponent } from "./desktop-layout.component";
+
+Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: jest.fn().mockImplementation((query) => ({
+ matches: true,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(),
+ removeListener: jest.fn(),
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ })),
+});
+
+describe("DesktopLayoutComponent", () => {
+ let component: DesktopLayoutComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [DesktopLayoutComponent, RouterModule.forRoot([]), NavigationModule],
+ providers: [
+ {
+ provide: I18nService,
+ useValue: mock(),
+ },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(DesktopLayoutComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("creates component", () => {
+ expect(component).toBeTruthy();
+ });
+
+ it("renders bit-layout component", () => {
+ const compiled = fixture.nativeElement;
+ const layoutElement = compiled.querySelector("bit-layout");
+
+ expect(layoutElement).toBeTruthy();
+ });
+
+ it("supports content projection for side-nav", () => {
+ const compiled = fixture.nativeElement;
+ const ngContent = compiled.querySelectorAll("ng-content");
+
+ expect(ngContent).toBeTruthy();
+ });
+});
diff --git a/apps/desktop/src/app/layout/desktop-layout.component.ts b/apps/desktop/src/app/layout/desktop-layout.component.ts
new file mode 100644
index 00000000000..695f3db37ab
--- /dev/null
+++ b/apps/desktop/src/app/layout/desktop-layout.component.ts
@@ -0,0 +1,14 @@
+import { CommonModule } from "@angular/common";
+import { ChangeDetectionStrategy, Component } from "@angular/core";
+import { RouterModule } from "@angular/router";
+
+import { LayoutComponent, NavigationModule } from "@bitwarden/components";
+
+@Component({
+ selector: "app-desktop-layout",
+ standalone: true,
+ imports: [CommonModule, RouterModule, LayoutComponent, NavigationModule],
+ templateUrl: "./desktop-layout.component.html",
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class DesktopLayoutComponent {}
diff --git a/apps/desktop/src/app/layout/desktop-layout.module.ts b/apps/desktop/src/app/layout/desktop-layout.module.ts
new file mode 100644
index 00000000000..efef167c95e
--- /dev/null
+++ b/apps/desktop/src/app/layout/desktop-layout.module.ts
@@ -0,0 +1,14 @@
+import { NgModule } from "@angular/core";
+
+import { NavigationModule } from "@bitwarden/components";
+
+import { DesktopLayoutComponent } from "./desktop-layout.component";
+import { DesktopSideNavComponent } from "./desktop-side-nav.component";
+
+@NgModule({
+ imports: [DesktopLayoutComponent, DesktopSideNavComponent],
+ exports: [NavigationModule, DesktopLayoutComponent, DesktopSideNavComponent],
+ declarations: [],
+ providers: [],
+})
+export class DesktopLayoutModule {}
diff --git a/apps/desktop/src/app/layout/desktop-side-nav.component.html b/apps/desktop/src/app/layout/desktop-side-nav.component.html
new file mode 100644
index 00000000000..ede3f9131b7
--- /dev/null
+++ b/apps/desktop/src/app/layout/desktop-side-nav.component.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/desktop/src/app/layout/desktop-side-nav.component.spec.ts b/apps/desktop/src/app/layout/desktop-side-nav.component.spec.ts
new file mode 100644
index 00000000000..59e743f430a
--- /dev/null
+++ b/apps/desktop/src/app/layout/desktop-side-nav.component.spec.ts
@@ -0,0 +1,74 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { mock } from "jest-mock-extended";
+
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { NavigationModule } from "@bitwarden/components";
+
+import { DesktopSideNavComponent } from "./desktop-side-nav.component";
+
+Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: jest.fn().mockImplementation((query) => ({
+ matches: true,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(),
+ removeListener: jest.fn(),
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ })),
+});
+
+describe("DesktopSideNavComponent", () => {
+ let component: DesktopSideNavComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [DesktopSideNavComponent, NavigationModule],
+ providers: [
+ {
+ provide: I18nService,
+ useValue: mock(),
+ },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(DesktopSideNavComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("creates component", () => {
+ expect(component).toBeTruthy();
+ });
+
+ it("renders bit-side-nav component", () => {
+ const compiled = fixture.nativeElement;
+ const sideNavElement = compiled.querySelector("bit-side-nav");
+
+ expect(sideNavElement).toBeTruthy();
+ });
+
+ it("uses primary variant by default", () => {
+ expect(component.variant()).toBe("primary");
+ });
+
+ it("accepts variant input", () => {
+ fixture.componentRef.setInput("variant", "secondary");
+ fixture.detectChanges();
+
+ expect(component.variant()).toBe("secondary");
+ });
+
+ it("passes variant to bit-side-nav", () => {
+ fixture.componentRef.setInput("variant", "secondary");
+ fixture.detectChanges();
+
+ const compiled = fixture.nativeElement;
+ const sideNavElement = compiled.querySelector("bit-side-nav");
+
+ expect(sideNavElement.getAttribute("ng-reflect-variant")).toBe("secondary");
+ });
+});
diff --git a/apps/desktop/src/app/layout/desktop-side-nav.component.ts b/apps/desktop/src/app/layout/desktop-side-nav.component.ts
new file mode 100644
index 00000000000..c74e9bc989a
--- /dev/null
+++ b/apps/desktop/src/app/layout/desktop-side-nav.component.ts
@@ -0,0 +1,15 @@
+import { CommonModule } from "@angular/common";
+import { ChangeDetectionStrategy, Component, input } from "@angular/core";
+
+import { NavigationModule, SideNavVariant } from "@bitwarden/components";
+
+@Component({
+ selector: "app-desktop-side-nav",
+ standalone: true,
+ templateUrl: "desktop-side-nav.component.html",
+ imports: [CommonModule, NavigationModule],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class DesktopSideNavComponent {
+ readonly variant = input("primary");
+}
diff --git a/apps/desktop/src/app/layout/user-layout.component.html b/apps/desktop/src/app/layout/user-layout.component.html
new file mode 100644
index 00000000000..ff3ba579425
--- /dev/null
+++ b/apps/desktop/src/app/layout/user-layout.component.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desktop/src/app/layout/user-layout.component.spec.ts b/apps/desktop/src/app/layout/user-layout.component.spec.ts
new file mode 100644
index 00000000000..00aa8486529
--- /dev/null
+++ b/apps/desktop/src/app/layout/user-layout.component.spec.ts
@@ -0,0 +1,102 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { RouterModule } from "@angular/router";
+import { mock } from "jest-mock-extended";
+
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+
+import { DesktopLayoutModule } from "./desktop-layout.module";
+import { UserLayoutComponent } from "./user-layout.component";
+
+Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: jest.fn().mockImplementation((query) => ({
+ matches: true,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(),
+ removeListener: jest.fn(),
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ })),
+});
+
+describe("UserLayoutComponent", () => {
+ let component: UserLayoutComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [UserLayoutComponent, RouterModule.forRoot([]), DesktopLayoutModule],
+ providers: [
+ {
+ provide: I18nService,
+ useValue: mock(),
+ },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(UserLayoutComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("creates component", () => {
+ expect(component).toBeTruthy();
+ });
+
+ it("renders desktop layout", () => {
+ const compiled = fixture.nativeElement;
+ const layoutElement = compiled.querySelector("app-desktop-layout");
+
+ expect(layoutElement).toBeTruthy();
+ });
+
+ it("renders desktop side nav", () => {
+ const compiled = fixture.nativeElement;
+ const sideNavElement = compiled.querySelector("app-desktop-side-nav");
+
+ expect(sideNavElement).toBeTruthy();
+ });
+
+ it("renders logo with correct properties", () => {
+ const compiled = fixture.nativeElement;
+ const logoElement = compiled.querySelector("bit-nav-logo");
+
+ expect(logoElement).toBeTruthy();
+ expect(logoElement.getAttribute("route")).toBe(".");
+ });
+
+ it("renders vault navigation item", () => {
+ const compiled = fixture.nativeElement;
+ const navItems = compiled.querySelectorAll("bit-nav-item");
+ const vaultItem = Array.from(navItems).find(
+ (item) => (item as Element).getAttribute("icon") === "bwi-vault",
+ ) as Element | undefined;
+
+ expect(vaultItem).toBeTruthy();
+ expect(vaultItem?.getAttribute("route")).toBe("new-vault");
+ });
+
+ it("renders send navigation item", () => {
+ const compiled = fixture.nativeElement;
+ const navItems = compiled.querySelectorAll("bit-nav-item");
+ const sendItem = Array.from(navItems).find(
+ (item) => (item as Element).getAttribute("icon") === "bwi-send",
+ ) as Element | undefined;
+
+ expect(sendItem).toBeTruthy();
+ expect(sendItem?.getAttribute("route")).toBe("new-sends");
+ });
+
+ it("renders router outlet", () => {
+ const compiled = fixture.nativeElement;
+ const routerOutlet = compiled.querySelector("router-outlet");
+
+ expect(routerOutlet).toBeTruthy();
+ });
+
+ it("has logo property set", () => {
+ expect(component["logo"]).toBeDefined();
+ });
+});
diff --git a/apps/desktop/src/app/layout/user-layout.component.ts b/apps/desktop/src/app/layout/user-layout.component.ts
new file mode 100644
index 00000000000..81031b19d9a
--- /dev/null
+++ b/apps/desktop/src/app/layout/user-layout.component.ts
@@ -0,0 +1,19 @@
+import { CommonModule } from "@angular/common";
+import { ChangeDetectionStrategy, Component } from "@angular/core";
+import { RouterModule } from "@angular/router";
+
+import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { PasswordManagerLogo } from "@bitwarden/assets/svg";
+
+import { DesktopLayoutModule } from "./desktop-layout.module";
+
+@Component({
+ selector: "app-user-layout",
+ standalone: true,
+ templateUrl: "user-layout.component.html",
+ imports: [CommonModule, RouterModule, JslibModule, DesktopLayoutModule],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class UserLayoutComponent {
+ protected readonly logo = PasswordManagerLogo;
+}
diff --git a/apps/desktop/src/app/tools/send-v2/sends.component.spec.ts b/apps/desktop/src/app/tools/send-v2/sends.component.spec.ts
new file mode 100644
index 00000000000..2156bc80b1d
--- /dev/null
+++ b/apps/desktop/src/app/tools/send-v2/sends.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+
+import { SendsComponent } from "./sends.component";
+
+describe("SendsComponent", () => {
+ let component: SendsComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [SendsComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(SendsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("creates component", () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/apps/desktop/src/app/tools/send-v2/sends.component.ts b/apps/desktop/src/app/tools/send-v2/sends.component.ts
new file mode 100644
index 00000000000..c8e9ac4c1d9
--- /dev/null
+++ b/apps/desktop/src/app/tools/send-v2/sends.component.ts
@@ -0,0 +1,10 @@
+import { Component, ChangeDetectionStrategy } from "@angular/core";
+
+@Component({
+ selector: "app-sends-v2",
+ standalone: true,
+ imports: [],
+ template: "Sends V2 Component
",
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class SendsComponent {}
diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.spec.ts b/apps/desktop/src/vault/app/vault-v3/vault.component.spec.ts
new file mode 100644
index 00000000000..89ba05055f8
--- /dev/null
+++ b/apps/desktop/src/vault/app/vault-v3/vault.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+
+import { VaultComponent } from "./vault.component";
+
+describe("VaultComponent", () => {
+ let component: VaultComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [VaultComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(VaultComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("creates component", () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.ts b/apps/desktop/src/vault/app/vault-v3/vault.component.ts
new file mode 100644
index 00000000000..0a4a38742fc
--- /dev/null
+++ b/apps/desktop/src/vault/app/vault-v3/vault.component.ts
@@ -0,0 +1,10 @@
+import { ChangeDetectionStrategy, Component } from "@angular/core";
+
+@Component({
+ selector: "app-vault-v3",
+ standalone: true,
+ imports: [],
+ template: "Vault V3 Component
",
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class VaultComponent {}
diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts
index 17d5f4e9df5..068a8f1e410 100644
--- a/libs/common/src/enums/feature-flag.enum.ts
+++ b/libs/common/src/enums/feature-flag.enum.ts
@@ -72,6 +72,9 @@ export enum FeatureFlag {
/* Innovation */
PM19148_InnovationArchive = "pm-19148-innovation-archive",
+
+ /* Desktop */
+ DesktopUiMigrationMilestone1 = "desktop-ui-migration-milestone-1",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -150,6 +153,9 @@ export const DefaultFeatureFlagValue = {
/* Innovation */
[FeatureFlag.PM19148_InnovationArchive]: FALSE,
+
+ /* Desktop */
+ [FeatureFlag.DesktopUiMigrationMilestone1]: FALSE,
} satisfies Record;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;