mirror of
https://github.com/bitwarden/browser
synced 2026-02-12 14:34:02 +00:00
Merge branch 'main' into km/beeep/epheremal-value-store-rust
This commit is contained in:
6
.github/CODEOWNERS
vendored
6
.github/CODEOWNERS
vendored
@@ -4,10 +4,6 @@
|
||||
#
|
||||
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
|
||||
## Secrets Manager team files ##
|
||||
bitwarden_license/bit-web/src/app/secrets-manager @bitwarden/team-secrets-manager-dev
|
||||
apps/web/src/app/secrets-manager/ @bitwarden/team-secrets-manager-dev
|
||||
|
||||
## Auth team files ##
|
||||
apps/browser/src/auth @bitwarden/team-auth-dev
|
||||
apps/cli/src/auth @bitwarden/team-auth-dev
|
||||
@@ -107,6 +103,8 @@ apps/web/src/app/layouts @bitwarden/team-design-system
|
||||
|
||||
## Desktop native module ##
|
||||
apps/desktop/desktop_native @bitwarden/team-platform-dev
|
||||
apps/desktop/desktop_native/objc/src/native/autofill @bitwarden/team-autofill-dev
|
||||
apps/desktop/desktop_native/core/src/autofill @bitwarden/team-autofill-dev
|
||||
|
||||
## Key management team files ##
|
||||
apps/desktop/src/key-management @bitwarden/team-key-management-dev
|
||||
|
||||
6
.github/renovate.json
vendored
6
.github/renovate.json
vendored
@@ -10,7 +10,7 @@
|
||||
},
|
||||
{
|
||||
"matchManagers": ["github-actions"],
|
||||
"commitMessagePrefix": "[deps] DevOps:"
|
||||
"commitMessagePrefix": "[deps] BRE:"
|
||||
},
|
||||
{
|
||||
"matchManagers": ["cargo"],
|
||||
@@ -39,6 +39,10 @@
|
||||
"groupName": "macOS/iOS bindings",
|
||||
"matchPackageNames": ["core-foundation", "security-framework", "security-framework-sys"]
|
||||
},
|
||||
{
|
||||
"groupName": "zbus",
|
||||
"matchPackageNames": ["zbus", "zbus_polkit"]
|
||||
},
|
||||
{
|
||||
"matchPackageNames": [
|
||||
"base64-loader",
|
||||
|
||||
2
.github/workflows/build-desktop.yml
vendored
2
.github/workflows/build-desktop.yml
vendored
@@ -170,7 +170,7 @@ jobs:
|
||||
- name: Set up environment
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get -y install pkg-config libxss-dev libsecret-1-dev rpm musl-dev musl-tools flatpak flatpak-builder
|
||||
sudo apt-get -y install pkg-config libxss-dev rpm musl-dev musl-tools flatpak flatpak-builder
|
||||
|
||||
- name: Set up Snap
|
||||
run: sudo snap install snapcraft --classic
|
||||
|
||||
2
.github/workflows/release-desktop-beta.yml
vendored
2
.github/workflows/release-desktop-beta.yml
vendored
@@ -138,7 +138,7 @@ jobs:
|
||||
- name: Set up environment
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get -y install pkg-config libxss-dev libsecret-1-dev rpm
|
||||
sudo apt-get -y install pkg-config libxss-dev rpm
|
||||
|
||||
- name: Set up Snap
|
||||
run: sudo snap install snapcraft --classic
|
||||
|
||||
@@ -57,6 +57,7 @@ const config: StorybookConfig = {
|
||||
return config;
|
||||
},
|
||||
docs: {},
|
||||
staticDirs: ["../apps/web/src/images"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -1319,6 +1319,12 @@
|
||||
"enterVerificationCodeApp": {
|
||||
"message": "Enter the 6 digit verification code from your authenticator app."
|
||||
},
|
||||
"authenticationTimeout": {
|
||||
"message": "Authentication timeout"
|
||||
},
|
||||
"authenticationSessionTimedOut": {
|
||||
"message": "The authentication session timed out. Please restart the login process."
|
||||
},
|
||||
"enterVerificationCodeEmail": {
|
||||
"message": "Enter the 6 digit verification code that was emailed to $EMAIL$.",
|
||||
"placeholders": {
|
||||
|
||||
@@ -438,8 +438,8 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
|
||||
const successful = await this.trySetupBiometrics();
|
||||
this.form.controls.biometric.setValue(successful);
|
||||
await this.biometricStateService.setBiometricUnlockEnabled(successful);
|
||||
if (!successful) {
|
||||
await this.biometricStateService.setBiometricUnlockEnabled(false);
|
||||
await this.biometricStateService.setFingerprintValidated(false);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise">
|
||||
<header>
|
||||
<div class="left">
|
||||
<a (click)="logOut()">{{ "logOut" | i18n }}</a>
|
||||
<button type="button" (click)="logOut()">{{ "logOut" | i18n }}</button>
|
||||
</div>
|
||||
<h1 class="center">
|
||||
<span class="title">{{ "updateMasterPassword" | i18n }}</span>
|
||||
|
||||
@@ -362,8 +362,6 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
||||
this.inlineMenuFido2Credentials.clear();
|
||||
this.storeInlineMenuFido2Credentials$.next(currentTab.id);
|
||||
|
||||
await this.generatePassword();
|
||||
|
||||
const ciphersViews = await this.getCipherViews(currentTab, updateAllCipherTypes);
|
||||
for (let cipherIndex = 0; cipherIndex < ciphersViews.length; cipherIndex++) {
|
||||
this.inlineMenuCiphers.set(`inline-menu-cipher-${cipherIndex}`, ciphersViews[cipherIndex]);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
EnvironmentSelectorRouteData,
|
||||
ExtensionDefaultOverlayPosition,
|
||||
} from "@bitwarden/angular/auth/components/environment-selector.component";
|
||||
import { TwoFactorTimeoutComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth-expired.component";
|
||||
import { unauthUiRefreshRedirect } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-redirect";
|
||||
import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap";
|
||||
import {
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
VaultIcon,
|
||||
LoginDecryptionOptionsComponent,
|
||||
DevicesIcon,
|
||||
TwoFactorTimeoutIcon,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
|
||||
@@ -199,6 +201,29 @@ const routes: Routes = [
|
||||
],
|
||||
},
|
||||
),
|
||||
{
|
||||
path: "",
|
||||
component: ExtensionAnonLayoutWrapperComponent,
|
||||
children: [
|
||||
{
|
||||
path: "2fa-timeout",
|
||||
canActivate: [unauthGuardFn(unauthRouteOverrides)],
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: TwoFactorTimeoutComponent,
|
||||
},
|
||||
],
|
||||
data: {
|
||||
pageTitle: {
|
||||
key: "authenticationTimeout",
|
||||
},
|
||||
pageIcon: TwoFactorTimeoutIcon,
|
||||
elevation: 1,
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "2fa-options",
|
||||
component: TwoFactorOptionsComponent,
|
||||
|
||||
@@ -49,7 +49,7 @@ export class FilePopoutUtilsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether to show a file popout callout message for Chromium-based browsers in Linux and Mac OS X Big Sur
|
||||
* Determines whether to show a file popout callout message for Chromium-based browsers in Linux and Mac OS X
|
||||
* @param win - The window context in which the check should be performed.
|
||||
* @returns True if the extension is not in a sidebar or popout; otherwise, false.
|
||||
*/
|
||||
@@ -66,8 +66,6 @@ export class FilePopoutUtilsService {
|
||||
}
|
||||
|
||||
private isUnsupportedMac(win: Window): boolean {
|
||||
return (
|
||||
this.platformUtilsService.isChrome() && win?.navigator?.appVersion.includes("Mac OS X 11")
|
||||
);
|
||||
return this.platformUtilsService.isChrome() && win?.navigator?.appVersion.includes("Mac OS X");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,27 @@
|
||||
{{ "new" | i18n }}
|
||||
</button>
|
||||
<bit-menu #itemOptions>
|
||||
<a bitMenuItem (click)="newItemNavigate(cipherType.Login)">
|
||||
<a bitMenuItem [routerLink]="['/add-cipher']" [queryParams]="buildQueryParams(cipherType.Login)">
|
||||
<i class="bwi bwi-globe" slot="start" aria-hidden="true"></i>
|
||||
{{ "typeLogin" | i18n }}
|
||||
</a>
|
||||
<a bitMenuItem (click)="newItemNavigate(cipherType.Card)">
|
||||
<a bitMenuItem [routerLink]="['/add-cipher']" [queryParams]="buildQueryParams(cipherType.Card)">
|
||||
<i class="bwi bwi-credit-card" slot="start" aria-hidden="true"></i>
|
||||
{{ "typeCard" | i18n }}
|
||||
</a>
|
||||
<a bitMenuItem (click)="newItemNavigate(cipherType.Identity)">
|
||||
<a
|
||||
bitMenuItem
|
||||
[routerLink]="['/add-cipher']"
|
||||
[queryParams]="buildQueryParams(cipherType.Identity)"
|
||||
>
|
||||
<i class="bwi bwi-id-card" slot="start" aria-hidden="true"></i>
|
||||
{{ "typeIdentity" | i18n }}
|
||||
</a>
|
||||
<a bitMenuItem (click)="newItemNavigate(cipherType.SecureNote)">
|
||||
<a
|
||||
bitMenuItem
|
||||
[routerLink]="['/add-cipher']"
|
||||
[queryParams]="buildQueryParams(cipherType.SecureNote)"
|
||||
>
|
||||
<i class="bwi bwi-sticky-note" slot="start" aria-hidden="true"></i>
|
||||
{{ "note" | i18n }}
|
||||
</a>
|
||||
|
||||
@@ -1,141 +1,163 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { Router } from "@angular/router";
|
||||
import { ActivatedRoute, RouterLink } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { ButtonModule, DialogService, MenuModule } from "@bitwarden/components";
|
||||
import { ButtonModule, DialogService, MenuModule, NoItemsModule } from "@bitwarden/components";
|
||||
|
||||
import { BrowserApi } from "../../../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
|
||||
import { AddEditQueryParams } from "../add-edit/add-edit-v2.component";
|
||||
import { AddEditFolderDialogComponent } from "../add-edit-folder-dialog/add-edit-folder-dialog.component";
|
||||
|
||||
import { NewItemDropdownV2Component, NewItemInitialValues } from "./new-item-dropdown-v2.component";
|
||||
|
||||
describe("NewItemDropdownV2Component", () => {
|
||||
let component: NewItemDropdownV2Component;
|
||||
let fixture: ComponentFixture<NewItemDropdownV2Component>;
|
||||
const open = jest.fn();
|
||||
const navigate = jest.fn();
|
||||
let dialogServiceMock: jest.Mocked<DialogService>;
|
||||
let browserApiMock: jest.Mocked<typeof BrowserApi>;
|
||||
|
||||
jest
|
||||
.spyOn(BrowserApi, "getTabFromCurrentWindow")
|
||||
.mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab);
|
||||
const mockTab = { url: "https://example.com" };
|
||||
|
||||
beforeAll(() => {
|
||||
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(mockTab as chrome.tabs.Tab);
|
||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false);
|
||||
jest.spyOn(Utils, "getHostname").mockReturnValue("example.com");
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
open.mockClear();
|
||||
navigate.mockClear();
|
||||
dialogServiceMock = mock<DialogService>();
|
||||
dialogServiceMock.open.mockClear();
|
||||
|
||||
const activatedRouteMock = {
|
||||
snapshot: { paramMap: { get: jest.fn() } },
|
||||
};
|
||||
|
||||
const i18nServiceMock = mock<I18nService>();
|
||||
const folderServiceMock = mock<FolderService>();
|
||||
const folderApiServiceAbstractionMock = mock<FolderApiServiceAbstraction>();
|
||||
const accountServiceMock = mock<AccountService>();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NewItemDropdownV2Component, MenuModule, ButtonModule, JslibModule, CommonModule],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: Router, useValue: { navigate } },
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterLink,
|
||||
ButtonModule,
|
||||
MenuModule,
|
||||
NoItemsModule,
|
||||
NewItemDropdownV2Component,
|
||||
],
|
||||
})
|
||||
.overrideProvider(DialogService, { useValue: { open } })
|
||||
.compileComponents();
|
||||
providers: [
|
||||
{ provide: DialogService, useValue: dialogServiceMock },
|
||||
{ provide: I18nService, useValue: i18nServiceMock },
|
||||
{ provide: ActivatedRoute, useValue: activatedRouteMock },
|
||||
{ provide: BrowserApi, useValue: browserApiMock },
|
||||
{ provide: FolderService, useValue: folderServiceMock },
|
||||
{ provide: FolderApiServiceAbstraction, useValue: folderApiServiceAbstractionMock },
|
||||
{ provide: AccountService, useValue: accountServiceMock },
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(NewItemDropdownV2Component);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("opens new folder dialog", () => {
|
||||
component.openFolderDialog();
|
||||
|
||||
expect(open).toHaveBeenCalledWith(AddEditFolderDialogComponent);
|
||||
});
|
||||
|
||||
describe("new item", () => {
|
||||
const emptyParams: AddEditQueryParams = {
|
||||
collectionId: undefined,
|
||||
organizationId: undefined,
|
||||
folderId: undefined,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(component, "newItemNavigate");
|
||||
});
|
||||
|
||||
it("navigates to new login", async () => {
|
||||
await component.newItemNavigate(CipherType.Login);
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith(["/add-cipher"], {
|
||||
queryParams: {
|
||||
type: CipherType.Login.toString(),
|
||||
name: "example.com",
|
||||
uri: "https://example.com",
|
||||
...emptyParams,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates to new card", async () => {
|
||||
await component.newItemNavigate(CipherType.Card);
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith(["/add-cipher"], {
|
||||
queryParams: { type: CipherType.Card.toString(), ...emptyParams },
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates to new identity", async () => {
|
||||
await component.newItemNavigate(CipherType.Identity);
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith(["/add-cipher"], {
|
||||
queryParams: { type: CipherType.Identity.toString(), ...emptyParams },
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates to new note", async () => {
|
||||
await component.newItemNavigate(CipherType.SecureNote);
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith(["/add-cipher"], {
|
||||
queryParams: { type: CipherType.SecureNote.toString(), ...emptyParams },
|
||||
});
|
||||
});
|
||||
|
||||
it("includes initial values", async () => {
|
||||
describe("buildQueryParams", () => {
|
||||
it("should build query params for a Login cipher when not popped out", async () => {
|
||||
await component.ngOnInit();
|
||||
component.initialValues = {
|
||||
folderId: "222-333-444",
|
||||
organizationId: "444-555-666",
|
||||
collectionId: "777-888-999",
|
||||
} as NewItemInitialValues;
|
||||
|
||||
await component.newItemNavigate(CipherType.Login);
|
||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false);
|
||||
jest.spyOn(Utils, "getHostname").mockReturnValue("example.com");
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith(["/add-cipher"], {
|
||||
queryParams: {
|
||||
type: CipherType.Login.toString(),
|
||||
folderId: "222-333-444",
|
||||
organizationId: "444-555-666",
|
||||
collectionId: "777-888-999",
|
||||
uri: "https://example.com",
|
||||
name: "example.com",
|
||||
},
|
||||
const params = component.buildQueryParams(CipherType.Login);
|
||||
|
||||
expect(params).toEqual({
|
||||
type: CipherType.Login.toString(),
|
||||
collectionId: "777-888-999",
|
||||
organizationId: "444-555-666",
|
||||
folderId: "222-333-444",
|
||||
uri: "https://example.com",
|
||||
name: "example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not include name or uri when the extension is popped out", async () => {
|
||||
it("should build query params for a Login cipher when popped out", () => {
|
||||
component.initialValues = {
|
||||
collectionId: "777-888-999",
|
||||
} as NewItemInitialValues;
|
||||
|
||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true);
|
||||
|
||||
const params = component.buildQueryParams(CipherType.Login);
|
||||
|
||||
expect(params).toEqual({
|
||||
type: CipherType.Login.toString(),
|
||||
collectionId: "777-888-999",
|
||||
});
|
||||
});
|
||||
|
||||
it("should build query params for a secure note", () => {
|
||||
component.initialValues = {
|
||||
folderId: "222-333-444",
|
||||
organizationId: "444-555-666",
|
||||
collectionId: "777-888-999",
|
||||
} as NewItemInitialValues;
|
||||
|
||||
await component.newItemNavigate(CipherType.Login);
|
||||
const params = component.buildQueryParams(CipherType.SecureNote);
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith(["/add-cipher"], {
|
||||
queryParams: {
|
||||
type: CipherType.Login.toString(),
|
||||
folderId: "222-333-444",
|
||||
organizationId: "444-555-666",
|
||||
collectionId: "777-888-999",
|
||||
},
|
||||
expect(params).toEqual({
|
||||
type: CipherType.SecureNote.toString(),
|
||||
collectionId: "777-888-999",
|
||||
});
|
||||
});
|
||||
|
||||
it("should build query params for an Identity", () => {
|
||||
component.initialValues = {
|
||||
collectionId: "777-888-999",
|
||||
} as NewItemInitialValues;
|
||||
|
||||
const params = component.buildQueryParams(CipherType.Identity);
|
||||
|
||||
expect(params).toEqual({
|
||||
type: CipherType.Identity.toString(),
|
||||
collectionId: "777-888-999",
|
||||
});
|
||||
});
|
||||
|
||||
it("should build query params for a Card", () => {
|
||||
component.initialValues = {
|
||||
collectionId: "777-888-999",
|
||||
} as NewItemInitialValues;
|
||||
|
||||
const params = component.buildQueryParams(CipherType.Card);
|
||||
|
||||
expect(params).toEqual({
|
||||
type: CipherType.Card.toString(),
|
||||
collectionId: "777-888-999",
|
||||
});
|
||||
});
|
||||
|
||||
it("should build query params for a SshKey", () => {
|
||||
component.initialValues = {
|
||||
collectionId: "777-888-999",
|
||||
} as NewItemInitialValues;
|
||||
|
||||
const params = component.buildQueryParams(CipherType.SshKey);
|
||||
|
||||
expect(params).toEqual({
|
||||
type: CipherType.SshKey.toString(),
|
||||
collectionId: "777-888-999",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { Router, RouterLink } from "@angular/router";
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
import { RouterLink } from "@angular/router";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
@@ -25,31 +25,31 @@ export interface NewItemInitialValues {
|
||||
standalone: true,
|
||||
imports: [NoItemsModule, JslibModule, CommonModule, ButtonModule, RouterLink, MenuModule],
|
||||
})
|
||||
export class NewItemDropdownV2Component {
|
||||
export class NewItemDropdownV2Component implements OnInit {
|
||||
cipherType = CipherType;
|
||||
|
||||
private tab?: chrome.tabs.Tab;
|
||||
/**
|
||||
* Optional initial values to pass to the add cipher form
|
||||
*/
|
||||
@Input()
|
||||
initialValues: NewItemInitialValues;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private dialogService: DialogService,
|
||||
) {}
|
||||
constructor(private dialogService: DialogService) {}
|
||||
|
||||
private async buildQueryParams(type: CipherType): Promise<AddEditQueryParams> {
|
||||
const tab = await BrowserApi.getTabFromCurrentWindow();
|
||||
async ngOnInit() {
|
||||
this.tab = await BrowserApi.getTabFromCurrentWindow();
|
||||
}
|
||||
|
||||
buildQueryParams(type: CipherType): AddEditQueryParams {
|
||||
const poppedOut = BrowserPopupUtils.inPopout(window);
|
||||
|
||||
const loginDetails: { uri?: string; name?: string } = {};
|
||||
|
||||
// When a Login Cipher is created and the extension is not popped out,
|
||||
// pass along the uri and name
|
||||
if (!poppedOut && type === CipherType.Login && tab) {
|
||||
loginDetails.uri = tab.url;
|
||||
loginDetails.name = Utils.getHostname(tab.url);
|
||||
if (!poppedOut && type === CipherType.Login && this.tab) {
|
||||
loginDetails.uri = this.tab.url;
|
||||
loginDetails.name = Utils.getHostname(this.tab.url);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -61,10 +61,6 @@ export class NewItemDropdownV2Component {
|
||||
};
|
||||
}
|
||||
|
||||
async newItemNavigate(type: CipherType) {
|
||||
await this.router.navigate(["/add-cipher"], { queryParams: await this.buildQueryParams(type) });
|
||||
}
|
||||
|
||||
openFolderDialog() {
|
||||
this.dialogService.open(AddEditFolderDialogComponent);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ import { Subject } from "rxjs";
|
||||
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AUTOFILL_ID } from "@bitwarden/common/autofill/constants";
|
||||
import {
|
||||
AUTOFILL_ID,
|
||||
COPY_PASSWORD_ID,
|
||||
COPY_USERNAME_ID,
|
||||
COPY_VERIFICATION_CODE_ID,
|
||||
} from "@bitwarden/common/autofill/constants";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -17,7 +22,10 @@ import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { CopyCipherFieldService } from "@bitwarden/vault";
|
||||
|
||||
import { BrowserApi } from "../../../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
|
||||
import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service";
|
||||
|
||||
import { VaultPopupAutofillService } from "./../../../services/vault-popup-autofill.service";
|
||||
@@ -34,17 +42,26 @@ describe("ViewV2Component", () => {
|
||||
const params$ = new Subject();
|
||||
const mockNavigate = jest.fn();
|
||||
const collect = jest.fn().mockResolvedValue(null);
|
||||
const doAutofill = jest.fn();
|
||||
const doAutofill = jest.fn().mockResolvedValue(true);
|
||||
const copy = jest.fn().mockResolvedValue(true);
|
||||
|
||||
const mockCipher = {
|
||||
id: "122-333-444",
|
||||
type: CipherType.Login,
|
||||
orgId: "222-444-555",
|
||||
login: {
|
||||
username: "test-username",
|
||||
password: "test-password",
|
||||
totp: "123",
|
||||
},
|
||||
};
|
||||
|
||||
const mockVaultPopupAutofillService = {
|
||||
doAutofill,
|
||||
};
|
||||
const mockCopyCipherFieldService = {
|
||||
copy,
|
||||
};
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
|
||||
|
||||
@@ -57,6 +74,7 @@ describe("ViewV2Component", () => {
|
||||
mockNavigate.mockClear();
|
||||
collect.mockClear();
|
||||
doAutofill.mockClear();
|
||||
copy.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ViewV2Component],
|
||||
@@ -91,6 +109,10 @@ describe("ViewV2Component", () => {
|
||||
canDeleteCipher$: jest.fn().mockReturnValue(true),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CopyCipherFieldService,
|
||||
useValue: mockCopyCipherFieldService,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -159,5 +181,46 @@ describe("ViewV2Component", () => {
|
||||
|
||||
expect(doAutofill).toHaveBeenCalledOnce();
|
||||
}));
|
||||
|
||||
it('invokes `copy` when action="copy-username"', fakeAsync(() => {
|
||||
params$.next({ action: COPY_USERNAME_ID });
|
||||
|
||||
flush(); // Resolve all promises
|
||||
|
||||
expect(copy).toHaveBeenCalledOnce();
|
||||
}));
|
||||
|
||||
it('invokes `copy` when action="copy-password"', fakeAsync(() => {
|
||||
params$.next({ action: COPY_PASSWORD_ID });
|
||||
|
||||
flush(); // Resolve all promises
|
||||
|
||||
expect(copy).toHaveBeenCalledOnce();
|
||||
}));
|
||||
|
||||
it('invokes `copy` when action="copy-totp"', fakeAsync(() => {
|
||||
params$.next({ action: COPY_VERIFICATION_CODE_ID });
|
||||
|
||||
flush(); // Resolve all promises
|
||||
|
||||
expect(copy).toHaveBeenCalledOnce();
|
||||
}));
|
||||
|
||||
it("closes the popout after a load action", fakeAsync(() => {
|
||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValueOnce(true);
|
||||
jest.spyOn(BrowserPopupUtils, "inSingleActionPopout").mockReturnValueOnce(true);
|
||||
const closeSpy = jest.spyOn(BrowserPopupUtils, "closeSingleActionPopout");
|
||||
const focusSpy = jest
|
||||
.spyOn(BrowserApi, "focusTab")
|
||||
.mockImplementation(() => Promise.resolve());
|
||||
|
||||
params$.next({ action: AUTOFILL_ID, senderTabId: 99 });
|
||||
|
||||
flush(); // Resolve all promises
|
||||
|
||||
expect(doAutofill).toHaveBeenCalledOnce();
|
||||
expect(focusSpy).toHaveBeenCalledWith(99);
|
||||
expect(closeSpy).toHaveBeenCalledOnce();
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,13 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AUTOFILL_ID, SHOW_AUTOFILL_BUTTON } from "@bitwarden/common/autofill/constants";
|
||||
import {
|
||||
AUTOFILL_ID,
|
||||
COPY_PASSWORD_ID,
|
||||
COPY_USERNAME_ID,
|
||||
COPY_VERIFICATION_CODE_ID,
|
||||
SHOW_AUTOFILL_BUTTON,
|
||||
} from "@bitwarden/common/autofill/constants";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -18,7 +24,6 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
|
||||
import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
@@ -28,19 +33,34 @@ import {
|
||||
SearchModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { CopyCipherFieldService } from "@bitwarden/vault";
|
||||
|
||||
import { PremiumUpgradePromptService } from "../../../../../../../../libs/common/src/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { CipherViewComponent } from "../../../../../../../../libs/vault/src/cipher-view";
|
||||
import { BrowserApi } from "../../../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
|
||||
import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component";
|
||||
import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service";
|
||||
import { BrowserPremiumUpgradePromptService } from "../../../services/browser-premium-upgrade-prompt.service";
|
||||
import { BrowserViewPasswordHistoryService } from "../../../services/browser-view-password-history.service";
|
||||
import { closeViewVaultItemPopout, VaultPopoutType } from "../../../utils/vault-popout-window";
|
||||
|
||||
import { PopupFooterComponent } from "./../../../../../platform/popup/layout/popup-footer.component";
|
||||
import { PopupHeaderComponent } from "./../../../../../platform/popup/layout/popup-header.component";
|
||||
import { PopupPageComponent } from "./../../../../../platform/popup/layout/popup-page.component";
|
||||
import { VaultPopupAutofillService } from "./../../../services/vault-popup-autofill.service";
|
||||
|
||||
/**
|
||||
* The types of actions that can be triggered when loading the view vault item popout via the
|
||||
* extension ContextMenu. See context-menu-clicked-handler.ts for more information.
|
||||
*/
|
||||
type LoadAction =
|
||||
| typeof AUTOFILL_ID
|
||||
| typeof SHOW_AUTOFILL_BUTTON
|
||||
| typeof COPY_USERNAME_ID
|
||||
| typeof COPY_PASSWORD_ID
|
||||
| typeof COPY_VERIFICATION_CODE_ID;
|
||||
|
||||
@Component({
|
||||
selector: "app-view-v2",
|
||||
templateUrl: "view-v2.component.html",
|
||||
@@ -68,10 +88,10 @@ export class ViewV2Component {
|
||||
headerText: string;
|
||||
cipher: CipherView;
|
||||
organization$: Observable<Organization>;
|
||||
folder$: Observable<FolderView>;
|
||||
canDeleteCipher$: Observable<boolean>;
|
||||
collections$: Observable<CollectionView[]>;
|
||||
loadAction: typeof AUTOFILL_ID | typeof SHOW_AUTOFILL_BUTTON;
|
||||
loadAction: LoadAction;
|
||||
senderTabId?: number;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@@ -86,6 +106,7 @@ export class ViewV2Component {
|
||||
private eventCollectionService: EventCollectionService,
|
||||
private popupRouterCacheService: PopupRouterCacheService,
|
||||
protected cipherAuthorizationService: CipherAuthorizationService,
|
||||
private copyCipherFieldService: CopyCipherFieldService,
|
||||
) {
|
||||
this.subscribeToParams();
|
||||
}
|
||||
@@ -95,13 +116,15 @@ export class ViewV2Component {
|
||||
.pipe(
|
||||
switchMap(async (params): Promise<CipherView> => {
|
||||
this.loadAction = params.action;
|
||||
this.senderTabId = params.senderTabId ? parseInt(params.senderTabId, 10) : undefined;
|
||||
return await this.getCipherData(params.cipherId);
|
||||
}),
|
||||
switchMap(async (cipher) => {
|
||||
this.cipher = cipher;
|
||||
this.headerText = this.setHeader(cipher.type);
|
||||
if (this.loadAction === AUTOFILL_ID) {
|
||||
await this.vaultPopupAutofillService.doAutofill(this.cipher);
|
||||
|
||||
if (this.loadAction) {
|
||||
await this._handleLoadAction(this.loadAction, this.senderTabId);
|
||||
}
|
||||
|
||||
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(cipher);
|
||||
@@ -211,4 +234,65 @@ export class ViewV2Component {
|
||||
protected showFooter(): boolean {
|
||||
return this.cipher && (!this.cipher.isDeleted || (this.cipher.isDeleted && this.cipher.edit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the load action for the view vault item popout. These actions are typically triggered
|
||||
* via the extension context menu. It is necessary to render the view for items that have password
|
||||
* reprompt enabled.
|
||||
* @param loadAction
|
||||
* @param senderTabId
|
||||
* @private
|
||||
*/
|
||||
private async _handleLoadAction(loadAction: LoadAction, senderTabId?: number): Promise<void> {
|
||||
let actionSuccess = false;
|
||||
|
||||
// Both vaultPopupAutofillService and copyCipherFieldService will perform password re-prompting internally.
|
||||
|
||||
switch (loadAction) {
|
||||
case "show-autofill-button":
|
||||
// This action simply shows the cipher view, no need to do anything.
|
||||
return;
|
||||
case "autofill":
|
||||
actionSuccess = await this.vaultPopupAutofillService.doAutofill(this.cipher, false);
|
||||
break;
|
||||
case "copy-username":
|
||||
actionSuccess = await this.copyCipherFieldService.copy(
|
||||
this.cipher.login.username,
|
||||
"username",
|
||||
this.cipher,
|
||||
);
|
||||
break;
|
||||
case "copy-password":
|
||||
actionSuccess = await this.copyCipherFieldService.copy(
|
||||
this.cipher.login.password,
|
||||
"password",
|
||||
this.cipher,
|
||||
);
|
||||
break;
|
||||
case "copy-totp":
|
||||
actionSuccess = await this.copyCipherFieldService.copy(
|
||||
this.cipher.login.totp,
|
||||
"totp",
|
||||
this.cipher,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (BrowserPopupUtils.inPopout(window)) {
|
||||
setTimeout(
|
||||
async () => {
|
||||
if (
|
||||
BrowserPopupUtils.inSingleActionPopout(window, VaultPopoutType.viewVaultItem) &&
|
||||
senderTabId
|
||||
) {
|
||||
await BrowserApi.focusTab(senderTabId);
|
||||
await closeViewVaultItemPopout(`${VaultPopoutType.viewVaultItem}_${this.cipher.id}`);
|
||||
} else {
|
||||
await this.popupRouterCacheService.back();
|
||||
}
|
||||
},
|
||||
actionSuccess ? 1000 : 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
type="button"
|
||||
class="box-content-row"
|
||||
appStopClick
|
||||
*ngIf="isSshKeysEnabled"
|
||||
(click)="selectType(cipherType.SshKey)"
|
||||
>
|
||||
<div class="row-main">
|
||||
|
||||
@@ -7,7 +7,9 @@ import { first, switchMap, takeUntil } from "rxjs/operators";
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model";
|
||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
@@ -62,6 +64,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
|
||||
selectedOrganization: string = null;
|
||||
showCollections = true;
|
||||
|
||||
isSshKeysEnabled = false;
|
||||
|
||||
private loadedTimeout: number;
|
||||
private selectedTimeout: number;
|
||||
private preventSelected = false;
|
||||
@@ -95,6 +99,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
|
||||
private location: Location,
|
||||
private vaultFilterService: VaultFilterService,
|
||||
private vaultBrowserStateService: VaultBrowserStateService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.noFolderListSize = 100;
|
||||
}
|
||||
@@ -166,6 +171,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
|
||||
.subscribe((isSearchable) => {
|
||||
this.isSearchable = isSearchable;
|
||||
});
|
||||
|
||||
this.isSshKeysEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
||||
@@ -24,8 +24,7 @@ export const GLOBAL_VAULT_UI_ONBOARDING = new KeyDefinition<boolean>(
|
||||
|
||||
@Injectable()
|
||||
export class VaultUiOnboardingService {
|
||||
// TODO: Update this date to the release date of the new Browser UI
|
||||
private onboardingUiReleaseDate = new Date("2024-07-25");
|
||||
private onboardingUiReleaseDate = new Date("2024-12-10");
|
||||
|
||||
private vaultUiOnboardingState: GlobalState<boolean> = this.stateProvider.getGlobal(
|
||||
GLOBAL_VAULT_UI_ONBOARDING,
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<button type="button" bitMenuItem (click)="restore(cipher)">
|
||||
{{ "restore" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="delete(cipher)">
|
||||
<button type="button" bitMenuItem *appCanDeleteCipher="cipher" (click)="delete(cipher)">
|
||||
{{ "deleteForever" | i18n }}
|
||||
</button>
|
||||
</bit-menu>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, Input } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
import { CanDeleteCipherDirective, PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
@Component({
|
||||
selector: "app-trash-list-items-container",
|
||||
@@ -29,10 +29,12 @@ import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
JslibModule,
|
||||
SectionComponent,
|
||||
SectionHeaderComponent,
|
||||
CanDeleteCipherDirective,
|
||||
MenuModule,
|
||||
IconButtonModule,
|
||||
TypographyModule,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TrashListItemsContainerComponent {
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { CalloutModule, NoItemsModule } from "@bitwarden/components";
|
||||
@@ -27,6 +27,7 @@ import { TrashListItemsContainerComponent } from "./trash-list-items-container/t
|
||||
CalloutModule,
|
||||
NoItemsModule,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TrashComponent {
|
||||
protected deletedCiphers$ = this.vaultPopupItemsService.deletedCiphers$;
|
||||
|
||||
@@ -21,7 +21,6 @@ import { LoginExport } from "@bitwarden/common/models/export/login.export";
|
||||
import { SecureNoteExport } from "@bitwarden/common/models/export/secure-note.export";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
@@ -58,7 +57,6 @@ export class GetCommand extends DownloadCommand {
|
||||
private auditService: AuditService,
|
||||
private keyService: KeyService,
|
||||
encryptService: EncryptService,
|
||||
private stateService: StateService,
|
||||
private searchService: SearchService,
|
||||
private apiService: ApiService,
|
||||
private organizationService: OrganizationService,
|
||||
|
||||
@@ -58,7 +58,6 @@ export class OssServeConfigurator {
|
||||
this.serviceContainer.auditService,
|
||||
this.serviceContainer.keyService,
|
||||
this.serviceContainer.encryptService,
|
||||
this.serviceContainer.stateService,
|
||||
this.serviceContainer.searchService,
|
||||
this.serviceContainer.apiService,
|
||||
this.serviceContainer.organizationService,
|
||||
|
||||
@@ -33,7 +33,7 @@ export class GenerateCommand {
|
||||
includeNumber: normalizedOptions.includeNumber,
|
||||
minNumber: normalizedOptions.minNumber,
|
||||
minSpecial: normalizedOptions.minSpecial,
|
||||
ambiguous: normalizedOptions.ambiguous,
|
||||
ambiguous: !normalizedOptions.ambiguous,
|
||||
};
|
||||
|
||||
const enforcedOptions = (await this.stateService.getIsAuthenticated())
|
||||
|
||||
@@ -144,7 +144,6 @@ export class SendProgram extends BaseProgram {
|
||||
this.serviceContainer.auditService,
|
||||
this.serviceContainer.keyService,
|
||||
this.serviceContainer.encryptService,
|
||||
this.serviceContainer.stateService,
|
||||
this.serviceContainer.searchService,
|
||||
this.serviceContainer.apiService,
|
||||
this.serviceContainer.organizationService,
|
||||
|
||||
@@ -179,7 +179,6 @@ export class VaultProgram extends BaseProgram {
|
||||
this.serviceContainer.auditService,
|
||||
this.serviceContainer.keyService,
|
||||
this.serviceContainer.encryptService,
|
||||
this.serviceContainer.stateService,
|
||||
this.serviceContainer.searchService,
|
||||
this.serviceContainer.apiService,
|
||||
this.serviceContainer.organizationService,
|
||||
|
||||
597
apps/desktop/desktop_native/Cargo.lock
generated
597
apps/desktop/desktop_native/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -10,23 +10,22 @@ default = ["sys"]
|
||||
manual_test = []
|
||||
|
||||
sys = [
|
||||
"dep:widestring",
|
||||
"dep:windows",
|
||||
"dep:core-foundation",
|
||||
"dep:security-framework",
|
||||
"dep:security-framework-sys",
|
||||
"dep:gio",
|
||||
"dep:libsecret",
|
||||
"dep:zbus",
|
||||
"dep:zbus_polkit",
|
||||
"dep:widestring",
|
||||
"dep:windows",
|
||||
"dep:core-foundation",
|
||||
"dep:security-framework",
|
||||
"dep:security-framework-sys",
|
||||
"dep:zbus",
|
||||
"dep:zbus_polkit",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
aes = "=0.8.4"
|
||||
anyhow = "=1.0.93"
|
||||
arboard = { version = "=3.4.1", default-features = false, features = [
|
||||
"wayland-data-control",
|
||||
"wayland-data-control",
|
||||
] }
|
||||
argon2 = { version = "=0.5.3", features = ["zeroize"] }
|
||||
async-stream = "=0.3.6"
|
||||
base64 = "=0.22.1"
|
||||
byteorder = "=1.5.0"
|
||||
@@ -45,10 +44,10 @@ scopeguard = "=1.2.0"
|
||||
sha2 = "=0.10.8"
|
||||
ssh-encoding = "=0.2.0"
|
||||
ssh-key = { version = "=0.6.7", default-features = false, features = [
|
||||
"encryption",
|
||||
"ed25519",
|
||||
"rsa",
|
||||
"getrandom",
|
||||
"encryption",
|
||||
"ed25519",
|
||||
"rsa",
|
||||
"getrandom",
|
||||
] }
|
||||
bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "b4e7f2fedbe3df8c35545feb000176d3e7b2bc32" }
|
||||
tokio = { version = "=1.41.1", features = ["io-util", "sync", "macros", "net"] }
|
||||
@@ -83,9 +82,10 @@ keytar = "=0.1.6"
|
||||
core-foundation = { version = "=0.10.0", optional = true }
|
||||
security-framework = { version = "=3.0.0", optional = true }
|
||||
security-framework-sys = { version = "=2.12.0", optional = true }
|
||||
desktop_objc = { path = "../objc" }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gio = { version = "=0.19.5", optional = true }
|
||||
libsecret = { version = "=0.5.0", optional = true }
|
||||
oo7 = "=0.3.3"
|
||||
|
||||
zbus = { version = "=4.4.0", optional = true }
|
||||
zbus_polkit = { version = "=4.0.0", optional = true }
|
||||
|
||||
5
apps/desktop/desktop_native/core/src/autofill/macos.rs
Normal file
5
apps/desktop/desktop_native/core/src/autofill/macos.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn run_command(value: String) -> Result<String> {
|
||||
desktop_objc::run_command(value).await
|
||||
}
|
||||
5
apps/desktop/desktop_native/core/src/autofill/mod.rs
Normal file
5
apps/desktop/desktop_native/core/src/autofill/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#[cfg_attr(target_os = "linux", path = "unix.rs")]
|
||||
#[cfg_attr(target_os = "windows", path = "windows.rs")]
|
||||
#[cfg_attr(target_os = "macos", path = "macos.rs")]
|
||||
mod autofill;
|
||||
pub use autofill::*;
|
||||
5
apps/desktop/desktop_native/core/src/autofill/unix.rs
Normal file
5
apps/desktop/desktop_native/core/src/autofill/unix.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn run_command(value: String) -> Result<String> {
|
||||
todo!("Unix does not support autofill");
|
||||
}
|
||||
5
apps/desktop/desktop_native/core/src/autofill/windows.rs
Normal file
5
apps/desktop/desktop_native/core/src/autofill/windows.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn run_command(value: String) -> Result<String> {
|
||||
todo!("Windows does not support autofill");
|
||||
}
|
||||
@@ -18,7 +18,7 @@ impl super::BiometricTrait for Biometric {
|
||||
bail!("platform not supported");
|
||||
}
|
||||
|
||||
fn get_biometric_secret(
|
||||
async fn get_biometric_secret(
|
||||
_service: &str,
|
||||
_account: &str,
|
||||
_key_material: Option<KeyMaterial>,
|
||||
@@ -26,7 +26,7 @@ impl super::BiometricTrait for Biometric {
|
||||
bail!("platform not supported");
|
||||
}
|
||||
|
||||
fn set_biometric_secret(
|
||||
async fn set_biometric_secret(
|
||||
_service: &str,
|
||||
_account: &str,
|
||||
_secret: &str,
|
||||
|
||||
@@ -22,20 +22,19 @@ pub struct OsDerivedKey {
|
||||
pub iv_b64: String,
|
||||
}
|
||||
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait BiometricTrait {
|
||||
#[allow(async_fn_in_trait)]
|
||||
async fn prompt(hwnd: Vec<u8>, message: String) -> Result<bool>;
|
||||
#[allow(async_fn_in_trait)]
|
||||
async fn available() -> Result<bool>;
|
||||
fn derive_key_material(secret: Option<&str>) -> Result<OsDerivedKey>;
|
||||
fn set_biometric_secret(
|
||||
async fn set_biometric_secret(
|
||||
service: &str,
|
||||
account: &str,
|
||||
secret: &str,
|
||||
key_material: Option<KeyMaterial>,
|
||||
iv_b64: &str,
|
||||
) -> Result<String>;
|
||||
fn get_biometric_secret(
|
||||
async fn get_biometric_secret(
|
||||
service: &str,
|
||||
account: &str,
|
||||
key_material: Option<KeyMaterial>,
|
||||
|
||||
@@ -73,7 +73,7 @@ impl super::BiometricTrait for Biometric {
|
||||
Ok(OsDerivedKey { key_b64, iv_b64 })
|
||||
}
|
||||
|
||||
fn set_biometric_secret(
|
||||
async fn set_biometric_secret(
|
||||
service: &str,
|
||||
account: &str,
|
||||
secret: &str,
|
||||
@@ -85,11 +85,11 @@ impl super::BiometricTrait for Biometric {
|
||||
))?;
|
||||
|
||||
let encrypted_secret = encrypt(secret, &key_material, iv_b64)?;
|
||||
crate::password::set_password(service, account, &encrypted_secret)?;
|
||||
crate::password::set_password(service, account, &encrypted_secret).await?;
|
||||
Ok(encrypted_secret)
|
||||
}
|
||||
|
||||
fn get_biometric_secret(
|
||||
async fn get_biometric_secret(
|
||||
service: &str,
|
||||
account: &str,
|
||||
key_material: Option<KeyMaterial>,
|
||||
@@ -98,7 +98,7 @@ impl super::BiometricTrait for Biometric {
|
||||
"Key material is required for polkit protected keys"
|
||||
))?;
|
||||
|
||||
let encrypted_secret = crate::password::get_password(service, account)?;
|
||||
let encrypted_secret = crate::password::get_password(service, account).await?;
|
||||
let secret = CipherString::from_str(&encrypted_secret)?;
|
||||
return Ok(decrypt(&secret, &key_material)?);
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ impl super::BiometricTrait for Biometric {
|
||||
Ok(OsDerivedKey { key_b64, iv_b64 })
|
||||
}
|
||||
|
||||
fn set_biometric_secret(
|
||||
async fn set_biometric_secret(
|
||||
service: &str,
|
||||
account: &str,
|
||||
secret: &str,
|
||||
@@ -133,11 +133,11 @@ impl super::BiometricTrait for Biometric {
|
||||
))?;
|
||||
|
||||
let encrypted_secret = encrypt(secret, &key_material, iv_b64)?;
|
||||
crate::password::set_password(service, account, &encrypted_secret)?;
|
||||
crate::password::set_password(service, account, &encrypted_secret).await?;
|
||||
Ok(encrypted_secret)
|
||||
}
|
||||
|
||||
fn get_biometric_secret(
|
||||
async fn get_biometric_secret(
|
||||
service: &str,
|
||||
account: &str,
|
||||
key_material: Option<KeyMaterial>,
|
||||
@@ -146,7 +146,7 @@ impl super::BiometricTrait for Biometric {
|
||||
"Key material is required for Windows Hello protected keys"
|
||||
))?;
|
||||
|
||||
let encrypted_secret = crate::password::get_password(service, account)?;
|
||||
let encrypted_secret = crate::password::get_password(service, account).await?;
|
||||
match CipherString::from_str(&encrypted_secret) {
|
||||
Ok(secret) => {
|
||||
// If the secret is a CipherString, it is encrypted and we need to decrypt it.
|
||||
@@ -292,9 +292,9 @@ mod tests {
|
||||
assert_eq!(decrypt(&secret, &key_material).unwrap(), "secret")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_biometric_secret_requires_key() {
|
||||
let result = <Biometric as BiometricTrait>::get_biometric_secret("", "", None);
|
||||
#[tokio::test]
|
||||
async fn get_biometric_secret_requires_key() {
|
||||
let result = <Biometric as BiometricTrait>::get_biometric_secret("", "", None).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err().to_string(),
|
||||
@@ -302,29 +302,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_biometric_secret_handles_unencrypted_secret() {
|
||||
scopeguard::defer! {
|
||||
crate::password::delete_password("test", "test").unwrap();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn get_biometric_secret_handles_unencrypted_secret() {
|
||||
let test = "test";
|
||||
let secret = "password";
|
||||
let key_material = KeyMaterial {
|
||||
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
|
||||
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
|
||||
};
|
||||
crate::password::set_password(test, test, secret).unwrap();
|
||||
crate::password::set_password(test, test, secret).await.unwrap();
|
||||
let result =
|
||||
<Biometric as BiometricTrait>::get_biometric_secret(test, test, Some(key_material))
|
||||
.await
|
||||
.unwrap();
|
||||
crate::password::delete_password("test", "test").await.unwrap();
|
||||
assert_eq!(result, secret);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_biometric_secret_handles_encrypted_secret() {
|
||||
scopeguard::defer! {
|
||||
crate::password::delete_password("test", "test").unwrap();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn get_biometric_secret_handles_encrypted_secret() {
|
||||
let test = "test";
|
||||
let secret =
|
||||
CipherString::from_str("0.l9fhDUP/wDJcKwmEzcb/3w==|uP4LcqoCCj5FxBDP77NV6Q==").unwrap(); // output from test_encrypt
|
||||
@@ -332,17 +328,19 @@ mod tests {
|
||||
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
|
||||
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
|
||||
};
|
||||
crate::password::set_password(test, test, &secret.to_string()).unwrap();
|
||||
crate::password::set_password(test, test, &secret.to_string()).await.unwrap();
|
||||
|
||||
let result =
|
||||
<Biometric as BiometricTrait>::get_biometric_secret(test, test, Some(key_material))
|
||||
.await
|
||||
.unwrap();
|
||||
crate::password::delete_password("test", "test").await.unwrap();
|
||||
assert_eq!(result, "secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_biometric_secret_requires_key() {
|
||||
let result = <Biometric as BiometricTrait>::set_biometric_secret("", "", "", None, "");
|
||||
#[tokio::test]
|
||||
async fn set_biometric_secret_requires_key() {
|
||||
let result = <Biometric as BiometricTrait>::set_biometric_secret("", "", "", None, "").await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err().to_string(),
|
||||
|
||||
@@ -5,7 +5,7 @@ use aes::cipher::{
|
||||
BlockEncryptMut, KeyIvInit,
|
||||
};
|
||||
|
||||
use crate::error::{CryptoError, Result};
|
||||
use crate::error::{CryptoError, KdfParamError, Result};
|
||||
|
||||
use super::CipherString;
|
||||
|
||||
@@ -37,3 +37,53 @@ pub fn encrypt_aes256(
|
||||
|
||||
Ok(CipherString::AesCbc256_B64 { iv, data })
|
||||
}
|
||||
|
||||
pub fn argon2(
|
||||
secret: &[u8],
|
||||
salt: &[u8],
|
||||
iterations: u32,
|
||||
memory: u32,
|
||||
parallelism: u32,
|
||||
) -> Result<[u8; 32]> {
|
||||
use argon2::*;
|
||||
|
||||
let params = Params::new(memory, iterations, parallelism, Some(32)).map_err(|e| {
|
||||
KdfParamError::InvalidParams(format!("Argon2 parameters are invalid: {e}",))
|
||||
})?;
|
||||
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||
|
||||
let mut hash = [0u8; 32];
|
||||
argon
|
||||
.hash_password_into(secret, &salt, &mut hash)
|
||||
.map_err(|e| KdfParamError::InvalidParams(format!("Argon2 hashing failed: {e}",)))?;
|
||||
|
||||
// Argon2 is using some stack memory that is not zeroed. Eventually some function will
|
||||
// overwrite the stack, but we use this trick to force the used stack to be zeroed.
|
||||
#[inline(never)]
|
||||
fn clear_stack() {
|
||||
std::hint::black_box([0u8; 4096]);
|
||||
}
|
||||
clear_stack();
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_argon2() {
|
||||
let test_hash: [u8; 32] = [
|
||||
112, 200, 85, 209, 100, 4, 246, 146, 117, 180, 152, 44, 103, 198, 75, 14, 166, 77, 201,
|
||||
22, 62, 178, 87, 224, 95, 209, 253, 68, 166, 209, 47, 218,
|
||||
];
|
||||
let secret = b"supersecurepassword";
|
||||
let salt = b"mail@example.com";
|
||||
let iterations = 3;
|
||||
let memory = 1024 * 64;
|
||||
let parallelism = 4;
|
||||
|
||||
let hash = argon2(secret, salt, iterations, memory, parallelism).unwrap();
|
||||
assert_eq!(hash, test_hash,);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ pub enum Error {
|
||||
|
||||
#[error("Cryptography Error, {0}")]
|
||||
Crypto(#[from] CryptoError),
|
||||
#[error("KDF Parameter Error, {0}")]
|
||||
KdfParam(#[from] KdfParamError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -29,6 +31,12 @@ pub enum CryptoError {
|
||||
KeyDecrypt,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum KdfParamError {
|
||||
#[error("Invalid KDF parameters: {0}")]
|
||||
InvalidParams(String),
|
||||
}
|
||||
|
||||
// Ensure that the error messages implement Send and Sync
|
||||
#[cfg(test)]
|
||||
const _: () = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod autofill;
|
||||
#[cfg(feature = "sys")]
|
||||
pub mod biometric;
|
||||
#[cfg(feature = "sys")]
|
||||
@@ -8,10 +9,10 @@ pub mod ipc;
|
||||
#[cfg(feature = "sys")]
|
||||
pub mod password;
|
||||
#[cfg(feature = "sys")]
|
||||
pub mod process_isolation;
|
||||
#[cfg(feature = "sys")]
|
||||
pub mod powermonitor;
|
||||
#[cfg(feature = "sys")]
|
||||
pub mod process_isolation;
|
||||
#[cfg(feature = "sys")]
|
||||
pub mod ssh_agent;
|
||||
#[cfg(feature = "sys")]
|
||||
pub mod epheremal_values;
|
||||
|
||||
@@ -3,26 +3,22 @@ use security_framework::passwords::{
|
||||
delete_generic_password, get_generic_password, set_generic_password,
|
||||
};
|
||||
|
||||
pub fn get_password(service: &str, account: &str) -> Result<String> {
|
||||
pub async fn get_password(service: &str, account: &str) -> Result<String> {
|
||||
let result = String::from_utf8(get_generic_password(&service, &account)?)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn get_password_keytar(service: &str, account: &str) -> Result<String> {
|
||||
get_password(service, account)
|
||||
}
|
||||
|
||||
pub fn set_password(service: &str, account: &str, password: &str) -> Result<()> {
|
||||
pub async fn set_password(service: &str, account: &str, password: &str) -> Result<()> {
|
||||
let result = set_generic_password(&service, &account, password.as_bytes())?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn delete_password(service: &str, account: &str) -> Result<()> {
|
||||
pub async fn delete_password(service: &str, account: &str) -> Result<()> {
|
||||
let result = delete_generic_password(&service, &account)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn is_available() -> Result<bool> {
|
||||
pub async fn is_available() -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -30,18 +26,17 @@ pub fn is_available() -> Result<bool> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test() {
|
||||
scopeguard::defer!(delete_password("BitwardenTest", "BitwardenTest").unwrap_or({}););
|
||||
set_password("BitwardenTest", "BitwardenTest", "Random").unwrap();
|
||||
#[tokio::test]
|
||||
async fn test() {
|
||||
set_password("BitwardenTest", "BitwardenTest", "Random").await.unwrap();
|
||||
assert_eq!(
|
||||
"Random",
|
||||
get_password("BitwardenTest", "BitwardenTest").unwrap()
|
||||
get_password("BitwardenTest", "BitwardenTest").await.unwrap()
|
||||
);
|
||||
delete_password("BitwardenTest", "BitwardenTest").unwrap();
|
||||
delete_password("BitwardenTest", "BitwardenTest").await.unwrap();
|
||||
|
||||
// Ensure password is deleted
|
||||
match get_password("BitwardenTest", "BitwardenTest") {
|
||||
match get_password("BitwardenTest", "BitwardenTest").await {
|
||||
Ok(_) => panic!("Got a result"),
|
||||
Err(e) => assert_eq!(
|
||||
"The specified item could not be found in the keychain.",
|
||||
@@ -50,9 +45,9 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_no_password() {
|
||||
match get_password("Unknown", "Unknown") {
|
||||
#[tokio::test]
|
||||
async fn test_error_no_password() {
|
||||
match get_password("Unknown", "Unknown").await {
|
||||
Ok(_) => panic!("Got a result"),
|
||||
Err(e) => assert_eq!(
|
||||
"The specified item could not be found in the keychain.",
|
||||
|
||||
@@ -1,106 +1,106 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use libsecret::{password_clear_sync, password_lookup_sync, password_store_sync, Schema};
|
||||
use oo7::dbus::{self};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub fn get_password(service: &str, account: &str) -> Result<String> {
|
||||
let res = password_lookup_sync(
|
||||
Some(&get_schema()),
|
||||
build_attributes(service, account),
|
||||
gio::Cancellable::NONE,
|
||||
)?;
|
||||
|
||||
match res {
|
||||
Some(s) => Ok(String::from(s)),
|
||||
None => Err(anyhow!("No password found")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_password_keytar(service: &str, account: &str) -> Result<String> {
|
||||
get_password(service, account)
|
||||
}
|
||||
|
||||
pub fn set_password(service: &str, account: &str, password: &str) -> Result<()> {
|
||||
let result = password_store_sync(
|
||||
Some(&get_schema()),
|
||||
build_attributes(service, account),
|
||||
Some(&libsecret::COLLECTION_DEFAULT),
|
||||
&format!("{}/{}", service, account),
|
||||
password,
|
||||
gio::Cancellable::NONE,
|
||||
)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn delete_password(service: &str, account: &str) -> Result<()> {
|
||||
let result = password_clear_sync(
|
||||
Some(&get_schema()),
|
||||
build_attributes(service, account),
|
||||
gio::Cancellable::NONE,
|
||||
)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn is_available() -> Result<bool> {
|
||||
let result = password_clear_sync(
|
||||
Some(&get_schema()),
|
||||
build_attributes("bitwardenSecretsAvailabilityTest", "test"),
|
||||
gio::Cancellable::NONE,
|
||||
);
|
||||
match result {
|
||||
Ok(_) => Ok(true),
|
||||
pub async fn get_password(service: &str, account: &str) -> Result<String> {
|
||||
match get_password_new(service, account).await {
|
||||
Ok(res) => Ok(res),
|
||||
Err(_) => {
|
||||
println!("secret-service unavailable: {:?}", result);
|
||||
Ok(false)
|
||||
get_password_legacy(service, account).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_schema() -> Schema {
|
||||
let mut attributes = std::collections::HashMap::new();
|
||||
attributes.insert("service", libsecret::SchemaAttributeType::String);
|
||||
attributes.insert("account", libsecret::SchemaAttributeType::String);
|
||||
|
||||
libsecret::Schema::new(
|
||||
"org.freedesktop.Secret.Generic",
|
||||
libsecret::SchemaFlags::NONE,
|
||||
attributes,
|
||||
)
|
||||
async fn get_password_new(service: &str, account: &str) -> Result<String> {
|
||||
let keyring = oo7::Keyring::new().await?;
|
||||
let attributes = HashMap::from([("service", service), ("account", account)]);
|
||||
let results = keyring.search_items(&attributes).await?;
|
||||
let res = results.get(0);
|
||||
match res {
|
||||
Some(res) => {
|
||||
let secret = res.secret().await?;
|
||||
Ok(String::from_utf8(secret.to_vec())?)
|
||||
},
|
||||
None => Err(anyhow!("no result"))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_attributes<'a>(service: &'a str, account: &'a str) -> HashMap<&'a str, &'a str> {
|
||||
let mut attributes = HashMap::new();
|
||||
attributes.insert("service", service);
|
||||
attributes.insert("account", account);
|
||||
// forces to read via secret service; remvove after 2025.03
|
||||
async fn get_password_legacy(service: &str, account: &str) -> Result<String> {
|
||||
println!("falling back to get legacy {} {}", service, account);
|
||||
let svc = dbus::Service::new().await?;
|
||||
let collection = svc.default_collection().await?;
|
||||
let keyring = oo7::Keyring::DBus(collection);
|
||||
let attributes = HashMap::from([("service", service), ("account", account)]);
|
||||
let results = keyring.search_items(&attributes).await?;
|
||||
let res = results.get(0);
|
||||
match res {
|
||||
Some(res) => {
|
||||
let secret = res.secret().await?;
|
||||
println!("deleting legacy secret service entry {} {}", service, account);
|
||||
keyring.delete(&attributes).await?;
|
||||
let secret_string = String::from_utf8(secret.to_vec())?;
|
||||
set_password(service, account, &secret_string).await?;
|
||||
Ok(secret_string)
|
||||
},
|
||||
None => Err(anyhow!("no result"))
|
||||
}
|
||||
}
|
||||
|
||||
attributes
|
||||
pub async fn set_password(service: &str, account: &str, password: &str) -> Result<()> {
|
||||
let keyring = oo7::Keyring::new().await?;
|
||||
let attributes = HashMap::from([("service", service), ("account", account)]);
|
||||
keyring.create_item("org.freedesktop.Secret.Generic", &attributes, password, true).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_password(service: &str, account: &str) -> Result<()> {
|
||||
let keyring = oo7::Keyring::new().await?;
|
||||
let attributes = HashMap::from([("service", service), ("account", account)]);
|
||||
keyring.delete(&attributes).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn is_available() -> Result<bool> {
|
||||
match oo7::Keyring::new().await {
|
||||
Ok(_) => Ok(true),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test() {
|
||||
scopeguard::defer!(delete_password("BitwardenTest", "BitwardenTest").unwrap_or({}););
|
||||
set_password("BitwardenTest", "BitwardenTest", "Random").unwrap();
|
||||
#[tokio::test]
|
||||
async fn test() {
|
||||
set_password("BitwardenTest", "BitwardenTest", "Random").await.unwrap();
|
||||
assert_eq!(
|
||||
"Random",
|
||||
get_password("BitwardenTest", "BitwardenTest").unwrap()
|
||||
get_password("BitwardenTest", "BitwardenTest").await.unwrap()
|
||||
);
|
||||
delete_password("BitwardenTest", "BitwardenTest").unwrap();
|
||||
delete_password("BitwardenTest", "BitwardenTest").await.unwrap();
|
||||
|
||||
// Ensure password is deleted
|
||||
match get_password("BitwardenTest", "BitwardenTest") {
|
||||
Ok(_) => panic!("Got a result"),
|
||||
Err(e) => assert_eq!("No password found", e.to_string()),
|
||||
match get_password("BitwardenTest", "BitwardenTest").await {
|
||||
Ok(_) => {
|
||||
panic!("Got a result")
|
||||
}
|
||||
Err(e) => assert_eq!(
|
||||
"no result",
|
||||
e.to_string()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_no_password() {
|
||||
match get_password("BitwardenTest", "BitwardenTest") {
|
||||
#[tokio::test]
|
||||
async fn test_error_no_password() {
|
||||
match get_password("Unknown", "Unknown").await {
|
||||
Ok(_) => panic!("Got a result"),
|
||||
Err(e) => assert_eq!("No password found", e.to_string()),
|
||||
Err(e) => assert_eq!(
|
||||
"no result",
|
||||
e.to_string()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use windows::{
|
||||
|
||||
const CRED_FLAGS_NONE: u32 = 0;
|
||||
|
||||
pub fn get_password<'a>(service: &str, account: &str) -> Result<String> {
|
||||
pub async fn get_password<'a>(service: &str, account: &str) -> Result<String> {
|
||||
let target_name = U16CString::from_str(target_name(service, account))?;
|
||||
|
||||
let mut credential: *mut CREDENTIALW = std::ptr::null_mut();
|
||||
@@ -45,39 +45,7 @@ pub fn get_password<'a>(service: &str, account: &str) -> Result<String> {
|
||||
Ok(String::from(password))
|
||||
}
|
||||
|
||||
// Remove this after sufficient releases
|
||||
pub fn get_password_keytar<'a>(service: &str, account: &str) -> Result<String> {
|
||||
let target_name = U16CString::from_str(target_name(service, account))?;
|
||||
|
||||
let mut credential: *mut CREDENTIALW = std::ptr::null_mut();
|
||||
let credential_ptr = &mut credential;
|
||||
|
||||
let result = unsafe {
|
||||
CredReadW(
|
||||
PCWSTR(target_name.as_ptr()),
|
||||
CRED_TYPE_GENERIC,
|
||||
CRED_FLAGS_NONE,
|
||||
credential_ptr,
|
||||
)
|
||||
};
|
||||
|
||||
scopeguard::defer!({
|
||||
unsafe { CredFree(credential as *mut _) };
|
||||
});
|
||||
|
||||
result?;
|
||||
|
||||
let password = unsafe {
|
||||
std::str::from_utf8_unchecked(std::slice::from_raw_parts(
|
||||
(*credential).CredentialBlob,
|
||||
(*credential).CredentialBlobSize as usize,
|
||||
))
|
||||
};
|
||||
|
||||
Ok(String::from(password))
|
||||
}
|
||||
|
||||
pub fn set_password(service: &str, account: &str, password: &str) -> Result<()> {
|
||||
pub async fn set_password(service: &str, account: &str, password: &str) -> Result<()> {
|
||||
let mut target_name = U16CString::from_str(target_name(service, account))?;
|
||||
let mut user_name = U16CString::from_str(account)?;
|
||||
let last_written = FILETIME {
|
||||
@@ -108,7 +76,7 @@ pub fn set_password(service: &str, account: &str, password: &str) -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_password(service: &str, account: &str) -> Result<()> {
|
||||
pub async fn delete_password(service: &str, account: &str) -> Result<()> {
|
||||
let target_name = U16CString::from_str(target_name(service, account))?;
|
||||
|
||||
unsafe {
|
||||
@@ -122,7 +90,7 @@ pub fn delete_password(service: &str, account: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_available() -> Result<bool> {
|
||||
pub async fn is_available() -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -142,36 +110,25 @@ fn convert_error(e: windows::core::Error) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test() {
|
||||
scopeguard::defer!(delete_password("BitwardenTest", "BitwardenTest").unwrap_or({}););
|
||||
set_password("BitwardenTest", "BitwardenTest", "Random").unwrap();
|
||||
#[tokio::test]
|
||||
async fn test() {
|
||||
set_password("BitwardenTest", "BitwardenTest", "Random").await.unwrap();
|
||||
assert_eq!(
|
||||
"Random",
|
||||
get_password("BitwardenTest", "BitwardenTest").unwrap()
|
||||
get_password("BitwardenTest", "BitwardenTest").await.unwrap()
|
||||
);
|
||||
delete_password("BitwardenTest", "BitwardenTest").unwrap();
|
||||
delete_password("BitwardenTest", "BitwardenTest").await.unwrap();
|
||||
|
||||
// Ensure password is deleted
|
||||
match get_password("BitwardenTest", "BitwardenTest") {
|
||||
match get_password("BitwardenTest", "BitwardenTest").await {
|
||||
Ok(_) => panic!("Got a result"),
|
||||
Err(e) => assert_eq!("Password not found.", e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_password_keytar() {
|
||||
scopeguard::defer!(delete_password("BitwardenTest", "BitwardenTest").unwrap_or({}););
|
||||
keytar::set_password("BitwardenTest", "BitwardenTest", "HelloFromKeytar").unwrap();
|
||||
assert_eq!(
|
||||
"HelloFromKeytar",
|
||||
get_password_keytar("BitwardenTest", "BitwardenTest").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_no_password() {
|
||||
match get_password("BitwardenTest", "BitwardenTest") {
|
||||
#[tokio::test]
|
||||
async fn test_error_no_password() {
|
||||
match get_password("BitwardenTest", "BitwardenTest").await {
|
||||
Ok(_) => panic!("Got a result"),
|
||||
Err(e) => assert_eq!("Password not found.", e.to_string()),
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ hex = "=0.4.3"
|
||||
anyhow = "=1.0.93"
|
||||
desktop_core = { path = "../core" }
|
||||
napi = { version = "=2.16.13", features = ["async"] }
|
||||
napi-derive = "=2.16.12"
|
||||
napi-derive = "=2.16.13"
|
||||
tokio = { version = "=1.41.1" }
|
||||
tokio-util = "=0.7.12"
|
||||
tokio-stream = "=0.1.15"
|
||||
|
||||
8
apps/desktop/desktop_native/napi/index.d.ts
vendored
8
apps/desktop/desktop_native/napi/index.d.ts
vendored
@@ -6,8 +6,6 @@
|
||||
export declare namespace passwords {
|
||||
/** Fetch the stored password from the keychain. */
|
||||
export function getPassword(service: string, account: string): Promise<string>
|
||||
/** Fetch the stored password from the keychain that was stored with Keytar. */
|
||||
export function getPasswordKeytar(service: string, account: string): Promise<string>
|
||||
/** Save the password to the keychain. Adds an entry if none exists otherwise updates the existing entry. */
|
||||
export function setPassword(service: string, account: string, password: string): Promise<void>
|
||||
/** Delete the stored password from the keychain. */
|
||||
@@ -132,3 +130,9 @@ export declare namespace epheremal_values {
|
||||
remove(key: string): void
|
||||
}
|
||||
}
|
||||
export declare namespace autofill {
|
||||
export function runCommand(value: string): Promise<string>
|
||||
}
|
||||
export declare namespace crypto {
|
||||
export function argon2(secret: Buffer, salt: Buffer, iterations: number, memory: number, parallelism: number): Promise<Buffer>
|
||||
}
|
||||
|
||||
@@ -9,13 +9,7 @@ pub mod passwords {
|
||||
#[napi]
|
||||
pub async fn get_password(service: String, account: String) -> napi::Result<String> {
|
||||
desktop_core::password::get_password(&service, &account)
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
/// Fetch the stored password from the keychain that was stored with Keytar.
|
||||
#[napi]
|
||||
pub async fn get_password_keytar(service: String, account: String) -> napi::Result<String> {
|
||||
desktop_core::password::get_password_keytar(&service, &account)
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -27,6 +21,7 @@ pub mod passwords {
|
||||
password: String,
|
||||
) -> napi::Result<()> {
|
||||
desktop_core::password::set_password(&service, &account, &password)
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -34,13 +29,16 @@ pub mod passwords {
|
||||
#[napi]
|
||||
pub async fn delete_password(service: String, account: String) -> napi::Result<()> {
|
||||
desktop_core::password::delete_password(&service, &account)
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
// Checks if the os secure storage is available
|
||||
#[napi]
|
||||
pub async fn is_available() -> napi::Result<bool> {
|
||||
desktop_core::password::is_available().map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
desktop_core::password::is_available()
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +79,7 @@ pub mod biometrics {
|
||||
key_material.map(|m| m.into()),
|
||||
&iv_b64,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -92,6 +91,7 @@ pub mod biometrics {
|
||||
) -> napi::Result<String> {
|
||||
let result =
|
||||
Biometric::get_biometric_secret(&service, &account, key_material.map(|m| m.into()))
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()));
|
||||
result
|
||||
}
|
||||
@@ -249,13 +249,17 @@ pub mod sshagent {
|
||||
pub async fn serve(
|
||||
callback: ThreadsafeFunction<(String, bool), CalleeHandled>,
|
||||
) -> napi::Result<SshAgentState> {
|
||||
let (auth_request_tx, mut auth_request_rx) = tokio::sync::mpsc::channel::<(u32, (String, bool))>(32);
|
||||
let (auth_response_tx, auth_response_rx) = tokio::sync::broadcast::channel::<(u32, bool)>(32);
|
||||
let (auth_request_tx, mut auth_request_rx) =
|
||||
tokio::sync::mpsc::channel::<(u32, (String, bool))>(32);
|
||||
let (auth_response_tx, auth_response_rx) =
|
||||
tokio::sync::broadcast::channel::<(u32, bool)>(32);
|
||||
let auth_response_tx_arc = Arc::new(Mutex::new(auth_response_tx));
|
||||
tokio::spawn(async move {
|
||||
let _ = auth_response_rx;
|
||||
|
||||
while let Some((request_id, (cipher_uuid, is_list_request))) = auth_request_rx.recv().await {
|
||||
while let Some((request_id, (cipher_uuid, is_list_request))) =
|
||||
auth_request_rx.recv().await
|
||||
{
|
||||
let cloned_request_id = request_id.clone();
|
||||
let cloned_cipher_uuid = cipher_uuid.clone();
|
||||
let cloned_response_tx_arc = auth_response_tx_arc.clone();
|
||||
@@ -265,23 +269,33 @@ pub mod sshagent {
|
||||
let cipher_uuid = cloned_cipher_uuid;
|
||||
let auth_response_tx_arc = cloned_response_tx_arc;
|
||||
let callback = cloned_callback;
|
||||
let promise_result: Result<Promise<bool>, napi::Error> =
|
||||
callback.call_async(Ok((cipher_uuid, is_list_request))).await;
|
||||
let promise_result: Result<Promise<bool>, napi::Error> = callback
|
||||
.call_async(Ok((cipher_uuid, is_list_request)))
|
||||
.await;
|
||||
match promise_result {
|
||||
Ok(promise_result) => match promise_result.await {
|
||||
Ok(result) => {
|
||||
let _ = auth_response_tx_arc.lock().await.send((request_id, result))
|
||||
let _ = auth_response_tx_arc
|
||||
.lock()
|
||||
.await
|
||||
.send((request_id, result))
|
||||
.expect("should be able to send auth response to agent");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("[SSH Agent Native Module] calling UI callback promise was rejected: {}", e);
|
||||
let _ = auth_response_tx_arc.lock().await.send((request_id, false))
|
||||
let _ = auth_response_tx_arc
|
||||
.lock()
|
||||
.await
|
||||
.send((request_id, false))
|
||||
.expect("should be able to send auth response to agent");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
println!("[SSH Agent Native Module] calling UI callback could not create promise: {}", e);
|
||||
let _ = auth_response_tx_arc.lock().await.send((request_id, false))
|
||||
let _ = auth_response_tx_arc
|
||||
.lock()
|
||||
.await
|
||||
.send((request_id, false))
|
||||
.expect("should be able to send auth response to agent");
|
||||
}
|
||||
}
|
||||
@@ -348,7 +362,9 @@ pub mod sshagent {
|
||||
#[napi]
|
||||
pub fn clear_keys(agent_state: &mut SshAgentState) -> napi::Result<()> {
|
||||
let bitwarden_agent_state = &mut agent_state.state;
|
||||
bitwarden_agent_state.clear_keys().map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
bitwarden_agent_state
|
||||
.clear_keys()
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
@@ -564,3 +580,30 @@ pub mod epheremal_values {
|
||||
}
|
||||
}
|
||||
}
|
||||
pub mod autofill {
|
||||
#[napi]
|
||||
pub async fn run_command(value: String) -> napi::Result<String> {
|
||||
desktop_core::autofill::run_command(value)
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub mod crypto {
|
||||
use napi::bindgen_prelude::Buffer;
|
||||
|
||||
#[napi]
|
||||
pub async fn argon2(
|
||||
secret: Buffer,
|
||||
salt: Buffer,
|
||||
iterations: u32,
|
||||
memory: u32,
|
||||
parallelism: u32,
|
||||
) -> napi::Result<Buffer> {
|
||||
desktop_core::crypto::argon2(&secret, &salt, iterations, memory, parallelism)
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
.map(|v| v.to_vec())
|
||||
.map(|v| Buffer::from(v))
|
||||
}
|
||||
}
|
||||
|
||||
21
apps/desktop/desktop_native/objc/Cargo.toml
Normal file
21
apps/desktop/desktop_native/objc/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
edition = "2021"
|
||||
license = "GPL-3.0"
|
||||
name = "desktop_objc"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
[dependencies]
|
||||
anyhow = "=1.0.93"
|
||||
thiserror = "=1.0.69"
|
||||
tokio = "1.39.1"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "=0.9.4"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1.0.104"
|
||||
glob = "0.3.1"
|
||||
22
apps/desktop/desktop_native/objc/build.rs
Normal file
22
apps/desktop/desktop_native/objc/build.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use glob::glob;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn main() {
|
||||
let mut builder = cc::Build::new();
|
||||
|
||||
// Auto compile all .m files in the src/native directory
|
||||
for entry in glob("src/native/**/*.m").expect("Failed to read glob pattern") {
|
||||
let path = entry.expect("Failed to read glob entry");
|
||||
builder.file(path.clone());
|
||||
println!("cargo::rerun-if-changed={}", path.display());
|
||||
}
|
||||
|
||||
builder
|
||||
.flag("-fobjc-arc") // Enable Auto Reference Counting (ARC)
|
||||
.compile("autofill");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn main() {
|
||||
// Crate is only supported on macOS
|
||||
}
|
||||
124
apps/desktop/desktop_native/objc/src/lib.rs
Normal file
124
apps/desktop/desktop_native/objc/src/lib.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use std::{
|
||||
ffi::{c_char, CStr, CString},
|
||||
os::raw::c_void,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
#[repr(C)]
|
||||
pub struct ObjCString {
|
||||
value: *const c_char,
|
||||
size: usize,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct CommandContext {
|
||||
tx: Option<tokio::sync::oneshot::Sender<String>>,
|
||||
}
|
||||
|
||||
impl CommandContext {
|
||||
pub fn new() -> (Self, tokio::sync::oneshot::Receiver<String>) {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
|
||||
|
||||
(CommandContext { tx: Some(tx) }, rx)
|
||||
}
|
||||
|
||||
pub fn send(&mut self, value: String) -> Result<()> {
|
||||
let tx = self.tx.take().context(
|
||||
"Failed to take Sender from CommandContext. Has this context already returned once?",
|
||||
)?;
|
||||
|
||||
tx.send(value).map_err(|_| {
|
||||
anyhow::anyhow!("Failed to send ObjCString from CommandContext to Rust code")
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn as_ptr(&mut self) -> *mut c_void {
|
||||
self as *mut Self as *mut c_void
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<ObjCString> for String {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: ObjCString) -> Result<Self> {
|
||||
let c_str = unsafe { CStr::from_ptr(value.value) };
|
||||
let str = c_str
|
||||
.to_str()
|
||||
.context("Failed to convert ObjC output string to &str for use in Rust")?;
|
||||
|
||||
Ok(str.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ObjCString {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
objc::freeObjCString(self);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod objc {
|
||||
use std::os::raw::c_void;
|
||||
|
||||
use super::*;
|
||||
|
||||
extern "C" {
|
||||
pub fn runCommand(context: *mut c_void, value: *const c_char);
|
||||
pub fn freeObjCString(value: &ObjCString);
|
||||
}
|
||||
|
||||
/// This function is called from the ObjC code to return the output of the command
|
||||
#[no_mangle]
|
||||
pub extern "C" fn commandReturn(context: &mut CommandContext, value: ObjCString) -> bool {
|
||||
let value: String = match value.try_into() {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Error: Failed to convert ObjCString to Rust string during commandReturn: {}",
|
||||
e
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
match context.send(value) {
|
||||
Ok(_) => 0,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Error: Failed to return ObjCString from ObjC code to Rust code: {}",
|
||||
e
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_command(input: String) -> Result<String> {
|
||||
// Convert input to type that can be passed to ObjC code
|
||||
let c_input = CString::new(input)
|
||||
.context("Failed to convert Rust input string to a CString for use in call to ObjC code")?;
|
||||
|
||||
let (mut context, rx) = CommandContext::new();
|
||||
|
||||
// Call ObjC code
|
||||
unsafe { objc::runCommand(context.as_ptr(), c_input.as_ptr()) };
|
||||
|
||||
// Convert output from ObjC code to Rust string
|
||||
let objc_output = rx.await?.try_into()?;
|
||||
|
||||
// Convert output from ObjC code to Rust string
|
||||
// let objc_output = output.try_into()?;
|
||||
|
||||
Ok(objc_output)
|
||||
}
|
||||
2
apps/desktop/desktop_native/objc/src/native/.clangd
Normal file
2
apps/desktop/desktop_native/objc/src/native/.clangd
Normal file
@@ -0,0 +1,2 @@
|
||||
CompileFlags:
|
||||
Add: [-fobjc-arc]
|
||||
@@ -0,0 +1,8 @@
|
||||
#ifndef STATUS_H
|
||||
#define STATUS_H
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
void status(void *context, NSDictionary *params);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,57 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <AuthenticationServices/ASCredentialIdentityStore.h>
|
||||
#import <AuthenticationServices/ASCredentialIdentityStoreState.h>
|
||||
#import "../../interop.h"
|
||||
#import "status.h"
|
||||
|
||||
void storeState(void (^callback)(ASCredentialIdentityStoreState*)) {
|
||||
if (@available(macos 11, *)) {
|
||||
ASCredentialIdentityStore *store = [ASCredentialIdentityStore sharedStore];
|
||||
[store getCredentialIdentityStoreStateWithCompletion:^(ASCredentialIdentityStoreState * _Nonnull state) {
|
||||
callback(state);
|
||||
}];
|
||||
} else {
|
||||
callback(nil);
|
||||
}
|
||||
}
|
||||
|
||||
BOOL fido2Supported() {
|
||||
if (@available(macos 14, *)) {
|
||||
return YES;
|
||||
} else {
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
|
||||
BOOL passwordSupported() {
|
||||
if (@available(macos 11, *)) {
|
||||
return YES;
|
||||
} else {
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
|
||||
void status(void* context, __attribute__((unused)) NSDictionary *params) {
|
||||
storeState(^(ASCredentialIdentityStoreState *state) {
|
||||
BOOL enabled = NO;
|
||||
BOOL supportsIncremental = NO;
|
||||
|
||||
if (state != nil) {
|
||||
enabled = state.isEnabled;
|
||||
supportsIncremental = state.supportsIncrementalUpdates;
|
||||
}
|
||||
|
||||
_return(context,
|
||||
_success(@{
|
||||
@"support": @{
|
||||
@"fido2": @(fido2Supported()),
|
||||
@"password": @(passwordSupported()),
|
||||
@"incrementalUpdates": @(supportsIncremental),
|
||||
},
|
||||
@"state": @{
|
||||
@"enabled": @(enabled),
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#ifndef SYNC_H
|
||||
#define SYNC_H
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
void runSync(void *context, NSDictionary *params);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,59 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <AuthenticationServices/ASCredentialIdentityStore.h>
|
||||
#import <AuthenticationServices/ASCredentialIdentityStoreState.h>
|
||||
#import <AuthenticationServices/ASCredentialServiceIdentifier.h>
|
||||
#import <AuthenticationServices/ASPasswordCredentialIdentity.h>
|
||||
#import <AuthenticationServices/ASPasskeyCredentialIdentity.h>
|
||||
#import "../../utils.h"
|
||||
#import "../../interop.h"
|
||||
#import "sync.h"
|
||||
|
||||
// 'run' is added to the name because it clashes with internal macOS function
|
||||
void runSync(void* context, NSDictionary *params) {
|
||||
NSArray *credentials = params[@"credentials"];
|
||||
|
||||
// Map credentials to ASPasswordCredential objects
|
||||
NSMutableArray *mappedCredentials = [NSMutableArray arrayWithCapacity:credentials.count];
|
||||
for (NSDictionary *credential in credentials) {
|
||||
NSString *type = credential[@"type"];
|
||||
|
||||
if ([type isEqualToString:@"password"]) {
|
||||
NSString *cipherId = credential[@"cipherId"];
|
||||
NSString *uri = credential[@"uri"];
|
||||
NSString *username = credential[@"username"];
|
||||
|
||||
ASCredentialServiceIdentifier *serviceId = [[ASCredentialServiceIdentifier alloc]
|
||||
initWithIdentifier:uri type:ASCredentialServiceIdentifierTypeURL];
|
||||
ASPasswordCredentialIdentity *credential = [[ASPasswordCredentialIdentity alloc]
|
||||
initWithServiceIdentifier:serviceId user:username recordIdentifier:cipherId];
|
||||
|
||||
[mappedCredentials addObject:credential];
|
||||
}
|
||||
|
||||
if ([type isEqualToString:@"fido2"]) {
|
||||
NSString *cipherId = credential[@"cipherId"];
|
||||
NSString *rpId = credential[@"rpId"];
|
||||
NSString *userName = credential[@"userName"];
|
||||
NSData *credentialId = decodeBase64URL(credential[@"credentialId"]);
|
||||
NSData *userHandle = decodeBase64URL(credential[@"userHandle"]);
|
||||
|
||||
ASPasskeyCredentialIdentity *credential = [[ASPasskeyCredentialIdentity alloc]
|
||||
initWithRelyingPartyIdentifier:rpId
|
||||
userName:userName
|
||||
credentialID:credentialId
|
||||
userHandle:userHandle
|
||||
recordIdentifier:cipherId];
|
||||
|
||||
[mappedCredentials addObject:credential];
|
||||
}
|
||||
}
|
||||
|
||||
[ASCredentialIdentityStore.sharedStore replaceCredentialIdentityEntries:mappedCredentials
|
||||
completion:^(__attribute__((unused)) BOOL success, NSError * _Nullable error) {
|
||||
if (error) {
|
||||
return _return(context, _error_er(error));
|
||||
}
|
||||
|
||||
_return(context, _success(@{@"added": @([mappedCredentials count])}));
|
||||
}];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#ifndef RUN_AUTOFILL_COMMAND_H
|
||||
#define RUN_AUTOFILL_COMMAND_H
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
void runAutofillCommand(void* context, NSDictionary *input);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,20 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
#import "commands/sync.h"
|
||||
#import "commands/status.h"
|
||||
#import "../interop.h"
|
||||
#import "../utils.h"
|
||||
#import "run_autofill_command.h"
|
||||
|
||||
void runAutofillCommand(void* context, NSDictionary *input) {
|
||||
NSString *command = input[@"command"];
|
||||
NSDictionary *params = input[@"params"];
|
||||
|
||||
if ([command isEqual:@"status"]) {
|
||||
return status(context, params);
|
||||
} else if ([command isEqual:@"sync"]) {
|
||||
return runSync(context, params);
|
||||
}
|
||||
|
||||
_return(context, _error([NSString stringWithFormat:@"Unknown command: %@", command]));
|
||||
}
|
||||
|
||||
47
apps/desktop/desktop_native/objc/src/native/interop.h
Normal file
47
apps/desktop/desktop_native/objc/src/native/interop.h
Normal file
@@ -0,0 +1,47 @@
|
||||
#ifndef INTEROP_H
|
||||
#define INTEROP_H
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
// Tips for developing Objective-C code:
|
||||
// - Use the `NSLog` function to log messages to the system log
|
||||
// - Example:
|
||||
// NSLog(@"An example log: %@", someVariable);
|
||||
// - Use the `@try` and `@catch` directives to catch exceptions
|
||||
|
||||
#if !__has_feature(objc_arc)
|
||||
// Auto Reference Counting makes memory management easier for Objective-C objects
|
||||
// Regular C objects still need to be managed manually
|
||||
#error ARC must be enabled!
|
||||
#endif
|
||||
|
||||
/// [Shared with Rust]
|
||||
/// Simple struct to hold a C-string and its length
|
||||
/// This is used to return strings created in Objective-C to Rust
|
||||
/// so that Rust can free the memory when it's done with the string
|
||||
struct ObjCString
|
||||
{
|
||||
char *value;
|
||||
size_t size;
|
||||
};
|
||||
|
||||
/// [Defined in Rust]
|
||||
/// External function callable from Objective-C to return a string to Rust
|
||||
extern bool commandReturn(void *context, struct ObjCString output);
|
||||
|
||||
/// [Callable from Rust]
|
||||
/// Frees the memory allocated for an ObjCString
|
||||
void freeObjCString(struct ObjCString *value);
|
||||
|
||||
// --- Helper functions to convert between Objective-C and Rust types ---
|
||||
|
||||
NSString *_success(NSDictionary *value);
|
||||
NSString *_error(NSString *error);
|
||||
NSString *_error_er(NSError *error);
|
||||
NSString *_error_ex(NSException *error);
|
||||
void _return(void *context, NSString *output);
|
||||
|
||||
struct ObjCString nsStringToObjCString(NSString *string);
|
||||
NSString *cStringToNSString(char *string);
|
||||
|
||||
#endif
|
||||
71
apps/desktop/desktop_native/objc/src/native/interop.m
Normal file
71
apps/desktop/desktop_native/objc/src/native/interop.m
Normal file
@@ -0,0 +1,71 @@
|
||||
#import "interop.h"
|
||||
#import "utils.h"
|
||||
|
||||
/// [Callable from Rust]
|
||||
/// Frees the memory allocated for an ObjCString
|
||||
void freeObjCString(struct ObjCString *value) {
|
||||
free(value->value);
|
||||
}
|
||||
|
||||
// --- Helper functions to convert between Objective-C and Rust types ---
|
||||
|
||||
NSString *_success(NSDictionary *value) {
|
||||
NSDictionary *wrapper = @{@"type": @"success", @"value": value};
|
||||
NSError *jsonError = nil;
|
||||
NSString *toReturn = serializeJson(wrapper, jsonError);
|
||||
|
||||
if (jsonError) {
|
||||
// Manually format message since there seems to be an issue with the JSON serialization
|
||||
return [NSString stringWithFormat:@"{\"type\": \"error\", \"error\": \"Error occurred while serializing error: %@\"}", jsonError];
|
||||
}
|
||||
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
NSString *_error(NSString *error) {
|
||||
NSDictionary *errorDictionary = @{@"type": @"error", @"error": error};
|
||||
NSError *jsonError = nil;
|
||||
NSString *toReturn = serializeJson(errorDictionary, jsonError);
|
||||
|
||||
if (jsonError) {
|
||||
// Manually format message since there seems to be an issue with the JSON serialization
|
||||
return [NSString stringWithFormat:@"{\"type\": \"error\", \"error\": \"Error occurred while serializing error: %@\"}", jsonError];
|
||||
}
|
||||
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
NSString *_error_er(NSError *error) {
|
||||
return _error([error localizedDescription]);
|
||||
}
|
||||
|
||||
NSString *_error_ex(NSException *error) {
|
||||
return _error([NSString stringWithFormat:@"%@ (%@): %@", error.name, error.reason, [error callStackSymbols]]);
|
||||
}
|
||||
|
||||
void _return(void* context, NSString *output) {
|
||||
if (!commandReturn(context, nsStringToObjCString(output))) {
|
||||
NSLog(@"Error: Failed to return command output");
|
||||
// NOTE: This will most likely crash the application
|
||||
@throw [NSException exceptionWithName:@"CommandReturnError" reason:@"Failed to return command output" userInfo:nil];
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts an NSString to an ObjCString struct
|
||||
struct ObjCString nsStringToObjCString(NSString* string) {
|
||||
size_t size = [string lengthOfBytesUsingEncoding:NSUTF8StringEncoding] + 1;
|
||||
char *value = malloc(size);
|
||||
[string getCString:value maxLength:size encoding:NSUTF8StringEncoding];
|
||||
|
||||
struct ObjCString objCString;
|
||||
objCString.value = value;
|
||||
objCString.size = size;
|
||||
|
||||
return objCString;
|
||||
}
|
||||
|
||||
/// Converts a C-string to an NSString
|
||||
NSString* cStringToNSString(char* string) {
|
||||
return [[NSString alloc] initWithUTF8String:string];
|
||||
}
|
||||
|
||||
39
apps/desktop/desktop_native/objc/src/native/run_command.m
Normal file
39
apps/desktop/desktop_native/objc/src/native/run_command.m
Normal file
@@ -0,0 +1,39 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
#import "autofill/run_autofill_command.h"
|
||||
#import "interop.h"
|
||||
#import "utils.h"
|
||||
|
||||
void pickAndRunCommand(void* context, NSDictionary *input) {
|
||||
NSString *namespace = input[@"namespace"];
|
||||
|
||||
if ([namespace isEqual:@"autofill"]) {
|
||||
return runAutofillCommand(context, input);
|
||||
}
|
||||
|
||||
_return(context, _error([NSString stringWithFormat:@"Unknown namespace: %@", namespace]));
|
||||
}
|
||||
|
||||
/// [Callable from Rust]
|
||||
/// Runs a command with the given input JSON
|
||||
/// This function is called from Rust and is the entry point for running Objective-C code.
|
||||
/// It takes a JSON string as input, deserializes it, runs the command, and serializes the output.
|
||||
/// It also catches any exceptions that occur during the command execution.
|
||||
void runCommand(void *context, char* inputJson) {
|
||||
@autoreleasepool {
|
||||
@try {
|
||||
NSString *inputString = cStringToNSString(inputJson);
|
||||
|
||||
NSError *error = nil;
|
||||
NSDictionary *input = parseJson(inputString, error);
|
||||
if (error) {
|
||||
NSLog(@"Error occured while deserializing input params: %@", error);
|
||||
return _return(context, _error([NSString stringWithFormat:@"Error occured while deserializing input params: %@", error]));
|
||||
}
|
||||
|
||||
pickAndRunCommand(context, input);
|
||||
} @catch (NSException *e) {
|
||||
NSLog(@"Error occurred while running Objective-C command: %@", e);
|
||||
_return(context, _error([NSString stringWithFormat:@"Error occurred while running Objective-C command: %@", e]));
|
||||
}
|
||||
}
|
||||
}
|
||||
11
apps/desktop/desktop_native/objc/src/native/utils.h
Normal file
11
apps/desktop/desktop_native/objc/src/native/utils.h
Normal file
@@ -0,0 +1,11 @@
|
||||
#ifndef UTILS_H
|
||||
#define UTILS_H
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NSDictionary *parseJson(NSString *jsonString, NSError *error);
|
||||
NSString *serializeJson(NSDictionary *dictionary, NSError *error);
|
||||
|
||||
NSData *decodeBase64URL(NSString *base64URLString);
|
||||
|
||||
#endif
|
||||
28
apps/desktop/desktop_native/objc/src/native/utils.m
Normal file
28
apps/desktop/desktop_native/objc/src/native/utils.m
Normal file
@@ -0,0 +1,28 @@
|
||||
#import "utils.h"
|
||||
|
||||
NSDictionary *parseJson(NSString *jsonString, NSError *error) {
|
||||
NSData *data = [jsonString dataUsingEncoding:NSUTF8StringEncoding];
|
||||
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
|
||||
if (error) {
|
||||
return nil;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
NSString *serializeJson(NSDictionary *dictionary, NSError *error) {
|
||||
NSData *data = [NSJSONSerialization dataWithJSONObject:dictionary options:0 error:&error];
|
||||
if (error) {
|
||||
return nil;
|
||||
}
|
||||
return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
|
||||
}
|
||||
|
||||
NSData *decodeBase64URL(NSString *base64URLString) {
|
||||
NSString *base64String = [base64URLString stringByReplacingOccurrencesOfString:@"-" withString:@"+"];
|
||||
base64String = [base64String stringByReplacingOccurrencesOfString:@"_" withString:@"/"];
|
||||
|
||||
NSData *nsdataFromBase64String = [[NSData alloc]
|
||||
initWithBase64EncodedString:base64String options:0];
|
||||
|
||||
return nsdataFromBase64String;
|
||||
}
|
||||
@@ -18,12 +18,7 @@
|
||||
"**/*",
|
||||
"!**/node_modules/@bitwarden/desktop-napi/**/*",
|
||||
"**/node_modules/@bitwarden/desktop-napi/index.js",
|
||||
"**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node",
|
||||
|
||||
"!**/node_modules/argon2/**/*",
|
||||
"**/node_modules/argon2/argon2.cjs",
|
||||
"**/node_modules/argon2/package.json",
|
||||
"**/node_modules/argon2/build/Release/argon2.node"
|
||||
"**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node"
|
||||
],
|
||||
"electronVersion": "32.1.1",
|
||||
"generateUpdatesFilesForAllChannels": true,
|
||||
@@ -229,7 +224,7 @@
|
||||
},
|
||||
"deb": {
|
||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||
"depends": ["libnotify4", "libxtst6", "libnss3", "libsecret-1-0", "libxss1"]
|
||||
"depends": ["libnotify4", "libxtst6", "libnss3", "libxss1"]
|
||||
},
|
||||
"appImage": {
|
||||
"artifactName": "${productName}-${version}-${arch}.${ext}"
|
||||
|
||||
@@ -19,17 +19,18 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||
If using the credential would require showing custom UI for authenticating the user, cancel
|
||||
the request with error code ASExtensionError.userInteractionRequired.
|
||||
|
||||
override func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) {
|
||||
let databaseIsUnlocked = true
|
||||
if (databaseIsUnlocked) {
|
||||
let passwordCredential = ASPasswordCredential(user: "j_appleseed", password: "apple1234")
|
||||
self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil)
|
||||
} else {
|
||||
self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code:ASExtensionError.userInteractionRequired.rawValue))
|
||||
}
|
||||
}
|
||||
*/
|
||||
*/
|
||||
|
||||
override func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) {
|
||||
// let databaseIsUnlocked = true
|
||||
// if (databaseIsUnlocked) {
|
||||
let passwordCredential = ASPasswordCredential(user: credentialIdentity.user, password: "example1234")
|
||||
self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil)
|
||||
// } else {
|
||||
// self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code:ASExtensionError.userInteractionRequired.rawValue))
|
||||
// }
|
||||
}
|
||||
|
||||
/*
|
||||
Implement this method if provideCredentialWithoutUserInteraction(for:) can fail with
|
||||
ASExtensionError.userInteractionRequired. In this case, the system may present your extension's
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"dist:mac": "npm run build && npm run pack:mac",
|
||||
"dist:mac:mas": "npm run build && npm run pack:mac:mas",
|
||||
"dist:mac:masdev": "npm run build && npm run pack:mac:masdev",
|
||||
"dist:mac:masdev:with-extension": "npm run build && npm run pack:mac:masdev:with-extension",
|
||||
"dist:win": "npm run build && npm run pack:win",
|
||||
"dist:win:ci": "npm run build && npm run pack:win:ci",
|
||||
"publish:lin": "npm run build && npm run clean:dist && electron-builder --linux --x64 -p always",
|
||||
|
||||
@@ -28,8 +28,9 @@ async function buildMacOs() {
|
||||
"-alltargets",
|
||||
"-configuration",
|
||||
"Release",
|
||||
"-xcconfig",
|
||||
paths.macOsConfig,
|
||||
// Uncomment when signing is fixed
|
||||
// "-xcconfig",
|
||||
// paths.macOsConfig,
|
||||
]);
|
||||
stdOutProc(proc);
|
||||
await new Promise((resolve, reject) =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DesktopDefaultOverlayPosition,
|
||||
EnvironmentSelectorComponent,
|
||||
} from "@bitwarden/angular/auth/components/environment-selector.component";
|
||||
import { TwoFactorTimeoutComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth-expired.component";
|
||||
import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap";
|
||||
import {
|
||||
authGuard,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
VaultIcon,
|
||||
LoginDecryptionOptionsComponent,
|
||||
DevicesIcon,
|
||||
TwoFactorTimeoutIcon,
|
||||
} from "@bitwarden/auth/angular";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
|
||||
@@ -96,6 +98,22 @@ const routes: Routes = [
|
||||
],
|
||||
},
|
||||
),
|
||||
{
|
||||
path: "2fa-timeout",
|
||||
component: AnonLayoutWrapperComponent,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: TwoFactorTimeoutComponent,
|
||||
},
|
||||
],
|
||||
data: {
|
||||
pageIcon: TwoFactorTimeoutIcon,
|
||||
pageTitle: {
|
||||
key: "authenticationTimeout",
|
||||
},
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
},
|
||||
{ path: "register", component: RegisterComponent },
|
||||
{
|
||||
path: "vault",
|
||||
|
||||
@@ -20,6 +20,7 @@ import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/va
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { KeyService as KeyServiceAbstraction } from "@bitwarden/key-management";
|
||||
|
||||
import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service";
|
||||
import { I18nRendererService } from "../../platform/services/i18n.renderer.service";
|
||||
import { SshAgentService } from "../../platform/services/ssh-agent.service";
|
||||
import { VersionService } from "../../platform/services/version.service";
|
||||
@@ -45,6 +46,7 @@ export class InitService {
|
||||
private accountService: AccountService,
|
||||
private versionService: VersionService,
|
||||
private sshAgentService: SshAgentService,
|
||||
private autofillService: DesktopAutofillService,
|
||||
@Inject(DOCUMENT) private document: Document,
|
||||
) {}
|
||||
|
||||
@@ -82,6 +84,8 @@ export class InitService {
|
||||
|
||||
const containerService = new ContainerService(this.keyService, this.encryptService);
|
||||
containerService.attachToGlobal(this.win);
|
||||
|
||||
await this.autofillService.init();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@ export class RendererCryptoFunctionService
|
||||
memory: number,
|
||||
parallelism: number,
|
||||
): Promise<Uint8Array> {
|
||||
if (typeof password === "string") {
|
||||
password = new TextEncoder().encode(password);
|
||||
}
|
||||
if (typeof salt === "string") {
|
||||
salt = new TextEncoder().encode(salt);
|
||||
}
|
||||
|
||||
return await ipc.platform.crypto.argon2(password, salt, iterations, memory, parallelism);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
|
||||
import { DefaultProcessReloadService } from "@bitwarden/common/key-management/services/default-process-reload.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
@@ -95,6 +96,7 @@ import { DesktopLoginApprovalComponentService } from "../../auth/login/desktop-l
|
||||
import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service";
|
||||
import { DesktopPinService } from "../../auth/services/desktop-pin.service";
|
||||
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
|
||||
import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service";
|
||||
import { ElectronBiometricsService } from "../../key-management/biometrics/electron-biometrics.service";
|
||||
import { flagEnabled } from "../../platform/flags";
|
||||
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
|
||||
@@ -307,6 +309,10 @@ const safeProviders: SafeProvider[] = [
|
||||
provide: DesktopAutofillSettingsService,
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: DesktopAutofillService,
|
||||
deps: [LogService, CipherServiceAbstraction, ConfigService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: NativeMessagingManifestService,
|
||||
useClass: NativeMessagingManifestService,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<button
|
||||
type="button"
|
||||
bitLink
|
||||
linkType="primary"
|
||||
bit-item-content
|
||||
aria-haspopup="true"
|
||||
(click)="openHistoryDialog()"
|
||||
|
||||
@@ -17,15 +17,7 @@ import {
|
||||
standalone: true,
|
||||
selector: "credential-generator",
|
||||
templateUrl: "credential-generator.component.html",
|
||||
imports: [
|
||||
DialogModule,
|
||||
ButtonModule,
|
||||
JslibModule,
|
||||
GeneratorModule,
|
||||
ItemModule,
|
||||
ButtonModule,
|
||||
LinkModule,
|
||||
],
|
||||
imports: [DialogModule, ButtonModule, JslibModule, GeneratorModule, ItemModule, LinkModule],
|
||||
})
|
||||
export class CredentialGeneratorComponent {
|
||||
constructor(private dialogService: DialogService) {}
|
||||
|
||||
9
apps/desktop/src/autofill/preload.ts
Normal file
9
apps/desktop/src/autofill/preload.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ipcRenderer } from "electron";
|
||||
|
||||
import { Command } from "../platform/main/autofill/command";
|
||||
import { RunCommandParams, RunCommandResult } from "../platform/main/autofill/native-autofill.main";
|
||||
|
||||
export default {
|
||||
runCommand: <C extends Command>(params: RunCommandParams<C>): Promise<RunCommandResult<C>> =>
|
||||
ipcRenderer.invoke("autofill.runCommand", params),
|
||||
};
|
||||
121
apps/desktop/src/autofill/services/desktop-autofill.service.ts
Normal file
121
apps/desktop/src/autofill/services/desktop-autofill.service.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Injectable, OnDestroy } from "@angular/core";
|
||||
import { EMPTY, Subject, distinctUntilChanged, mergeMap, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { getCredentialsForAutofill } from "@bitwarden/common/platform/services/fido2/fido2-autofill-utils";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { NativeAutofillStatusCommand } from "../../platform/main/autofill/status.command";
|
||||
import {
|
||||
NativeAutofillFido2Credential,
|
||||
NativeAutofillPasswordCredential,
|
||||
NativeAutofillSyncCommand,
|
||||
} from "../../platform/main/autofill/sync.command";
|
||||
|
||||
@Injectable()
|
||||
export class DesktopAutofillService implements OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private logService: LogService,
|
||||
private cipherService: CipherService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
this.configService
|
||||
.getFeatureFlag$(FeatureFlag.MacOsNativeCredentialSync)
|
||||
.pipe(
|
||||
distinctUntilChanged(),
|
||||
switchMap((enabled) => {
|
||||
if (!enabled) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return this.cipherService.cipherViews$;
|
||||
}),
|
||||
// TODO: This will unset all the autofill credentials on the OS
|
||||
// when the account locks. We should instead explicilty clear the credentials
|
||||
// when the user logs out. Maybe by subscribing to the encrypted ciphers observable instead.
|
||||
mergeMap((cipherViewMap) => this.sync(Object.values(cipherViewMap ?? []))),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/** Give metadata about all available credentials in the users vault */
|
||||
async sync(cipherViews: CipherView[]) {
|
||||
const status = await this.status();
|
||||
if (status.type === "error") {
|
||||
return this.logService.error("Error getting autofill status", status.error);
|
||||
}
|
||||
|
||||
if (!status.value.state.enabled) {
|
||||
// Autofill is disabled
|
||||
return;
|
||||
}
|
||||
|
||||
let fido2Credentials: NativeAutofillFido2Credential[];
|
||||
let passwordCredentials: NativeAutofillPasswordCredential[];
|
||||
|
||||
if (status.value.support.password) {
|
||||
passwordCredentials = cipherViews
|
||||
.filter(
|
||||
(cipher) =>
|
||||
cipher.type === CipherType.Login &&
|
||||
cipher.login.uris?.length > 0 &&
|
||||
cipher.login.uris.some((uri) => uri.match !== UriMatchStrategy.Never) &&
|
||||
cipher.login.uris.some((uri) => !Utils.isNullOrWhitespace(uri.uri)) &&
|
||||
!Utils.isNullOrWhitespace(cipher.login.username),
|
||||
)
|
||||
.map((cipher) => ({
|
||||
type: "password",
|
||||
cipherId: cipher.id,
|
||||
uri: cipher.login.uris.find((uri) => uri.match !== UriMatchStrategy.Never).uri,
|
||||
username: cipher.login.username,
|
||||
}));
|
||||
}
|
||||
|
||||
if (status.value.support.fido2) {
|
||||
fido2Credentials = (await getCredentialsForAutofill(cipherViews)).map((credential) => ({
|
||||
type: "fido2",
|
||||
...credential,
|
||||
}));
|
||||
}
|
||||
|
||||
const syncResult = await ipc.autofill.runCommand<NativeAutofillSyncCommand>({
|
||||
namespace: "autofill",
|
||||
command: "sync",
|
||||
params: {
|
||||
credentials: [...fido2Credentials, ...passwordCredentials],
|
||||
},
|
||||
});
|
||||
|
||||
if (syncResult.type === "error") {
|
||||
return this.logService.error("Error syncing autofill credentials", syncResult.error);
|
||||
}
|
||||
|
||||
this.logService.debug(`Synced ${syncResult.value.added} autofill credentials`);
|
||||
}
|
||||
|
||||
/** Get autofill status from OS */
|
||||
private status() {
|
||||
// TODO: Investigate why this type needs to be explicitly set
|
||||
return ipc.autofill.runCommand<NativeAutofillStatusCommand>({
|
||||
namespace: "autofill",
|
||||
command: "status",
|
||||
params: {},
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
@@ -919,6 +919,12 @@
|
||||
"baseUrl": {
|
||||
"message": "Server URL"
|
||||
},
|
||||
"authenticationTimeout": {
|
||||
"message": "Authentication timeout"
|
||||
},
|
||||
"authenticationSessionTimedOut": {
|
||||
"message": "The authentication session timed out. Please restart the login process."
|
||||
},
|
||||
"selfHostBaseUrl": {
|
||||
"message": "Self-host server URL",
|
||||
"description": "Label for field requesting a self-hosted integration service URL"
|
||||
|
||||
@@ -35,6 +35,7 @@ import { PowerMonitorMain } from "./main/power-monitor.main";
|
||||
import { TrayMain } from "./main/tray.main";
|
||||
import { UpdaterMain } from "./main/updater.main";
|
||||
import { WindowMain } from "./main/window.main";
|
||||
import { NativeAutofillMain } from "./platform/main/autofill/native-autofill.main";
|
||||
import { ClipboardMain } from "./platform/main/clipboard.main";
|
||||
import { DesktopCredentialStorageListener } from "./platform/main/desktop-credential-storage-listener";
|
||||
import { MainCryptoFunctionService } from "./platform/main/main-crypto-function.service";
|
||||
@@ -72,6 +73,7 @@ export class Main {
|
||||
biometricsService: DesktopBiometricsService;
|
||||
nativeMessagingMain: NativeMessagingMain;
|
||||
clipboardMain: ClipboardMain;
|
||||
nativeAutofillMain: NativeAutofillMain;
|
||||
desktopAutofillSettingsService: DesktopAutofillSettingsService;
|
||||
versionMain: VersionMain;
|
||||
sshAgentService: MainSshAgentService;
|
||||
@@ -256,6 +258,9 @@ export class Main {
|
||||
|
||||
new EphemeralValueStorageService();
|
||||
new SSOLocalhostCallbackService(this.environmentService, this.messagingService);
|
||||
|
||||
this.nativeAutofillMain = new NativeAutofillMain(this.logService);
|
||||
void this.nativeAutofillMain.init();
|
||||
}
|
||||
|
||||
bootstrap() {
|
||||
|
||||
3
apps/desktop/src/package-lock.json
generated
3
apps/desktop/src/package-lock.json
generated
@@ -9,8 +9,7 @@
|
||||
"version": "2024.12.0",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@bitwarden/desktop-napi": "file:../desktop_native/napi",
|
||||
"argon2": "0.41.1"
|
||||
"@bitwarden/desktop-napi": "file:../desktop_native/napi"
|
||||
}
|
||||
},
|
||||
"../desktop_native/napi": {
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"url": "git+https://github.com/bitwarden/clients.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bitwarden/desktop-napi": "file:../desktop_native/napi",
|
||||
"argon2": "0.41.1"
|
||||
"@bitwarden/desktop-napi": "file:../desktop_native/napi"
|
||||
}
|
||||
}
|
||||
|
||||
23
apps/desktop/src/platform/main/autofill/command.ts
Normal file
23
apps/desktop/src/platform/main/autofill/command.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NativeAutofillStatusCommand } from "./status.command";
|
||||
import { NativeAutofillSyncCommand } from "./sync.command";
|
||||
|
||||
export type CommandDefinition = {
|
||||
namespace: string;
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
output: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CommandOutput<SuccessOutput> =
|
||||
| {
|
||||
type: "error";
|
||||
error: string;
|
||||
}
|
||||
| { type: "success"; value: SuccessOutput };
|
||||
|
||||
export type IpcCommandInvoker<C extends CommandDefinition> = (
|
||||
params: C["input"],
|
||||
) => Promise<CommandOutput<C["output"]>>;
|
||||
|
||||
/** A list of all available commands */
|
||||
export type Command = NativeAutofillSyncCommand | NativeAutofillStatusCommand;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { autofill } from "@bitwarden/desktop-napi";
|
||||
|
||||
import { CommandDefinition } from "./command";
|
||||
|
||||
export type RunCommandParams<C extends CommandDefinition> = {
|
||||
namespace: C["namespace"];
|
||||
command: C["name"];
|
||||
params: C["input"];
|
||||
};
|
||||
|
||||
export type RunCommandResult<C extends CommandDefinition> = C["output"];
|
||||
|
||||
export class NativeAutofillMain {
|
||||
constructor(private logService: LogService) {}
|
||||
|
||||
async init() {
|
||||
ipcMain.handle(
|
||||
"autofill.runCommand",
|
||||
<C extends CommandDefinition>(
|
||||
_event: any,
|
||||
params: RunCommandParams<C>,
|
||||
): Promise<RunCommandResult<C>> => {
|
||||
return this.runCommand(params);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async runCommand<C extends CommandDefinition>(
|
||||
command: RunCommandParams<C>,
|
||||
): Promise<RunCommandResult<C>> {
|
||||
try {
|
||||
const result = await autofill.runCommand(JSON.stringify(command));
|
||||
const parsed = JSON.parse(result) as RunCommandResult<C>;
|
||||
|
||||
if (parsed.type === "error") {
|
||||
this.logService.error(`Error running autofill command '${command.command}':`, parsed.error);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
this.logService.error(`Error running autofill command '${command.command}':`, e);
|
||||
|
||||
if (e instanceof Error) {
|
||||
return { type: "error", error: e.stack ?? String(e) } as RunCommandResult<C>;
|
||||
}
|
||||
|
||||
return { type: "error", error: String(e) } as RunCommandResult<C>;
|
||||
}
|
||||
}
|
||||
}
|
||||
20
apps/desktop/src/platform/main/autofill/status.command.ts
Normal file
20
apps/desktop/src/platform/main/autofill/status.command.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { CommandDefinition, CommandOutput } from "./command";
|
||||
|
||||
export interface NativeAutofillStatusCommand extends CommandDefinition {
|
||||
name: "status";
|
||||
input: NativeAutofillStatusParams;
|
||||
output: NativeAutofillStatusResult;
|
||||
}
|
||||
|
||||
export type NativeAutofillStatusParams = Record<string, never>;
|
||||
|
||||
export type NativeAutofillStatusResult = CommandOutput<{
|
||||
support: {
|
||||
fido2: boolean;
|
||||
password: boolean;
|
||||
incrementalUpdates: boolean;
|
||||
};
|
||||
state: {
|
||||
enabled: boolean;
|
||||
};
|
||||
}>;
|
||||
37
apps/desktop/src/platform/main/autofill/sync.command.ts
Normal file
37
apps/desktop/src/platform/main/autofill/sync.command.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { CommandDefinition, CommandOutput } from "./command";
|
||||
|
||||
export interface NativeAutofillSyncCommand extends CommandDefinition {
|
||||
name: "sync";
|
||||
input: NativeAutofillSyncParams;
|
||||
output: NativeAutofillSyncResult;
|
||||
}
|
||||
|
||||
export type NativeAutofillSyncParams = {
|
||||
credentials: NativeAutofillCredential[];
|
||||
};
|
||||
|
||||
export type NativeAutofillCredential =
|
||||
| NativeAutofillFido2Credential
|
||||
| NativeAutofillPasswordCredential;
|
||||
|
||||
export type NativeAutofillFido2Credential = {
|
||||
type: "fido2";
|
||||
cipherId: string;
|
||||
rpId: string;
|
||||
userName: string;
|
||||
/** Should be Base64URL-encoded binary data */
|
||||
credentialId: string;
|
||||
/** Should be Base64URL-encoded binary data */
|
||||
userHandle: string;
|
||||
};
|
||||
|
||||
export type NativeAutofillPasswordCredential = {
|
||||
type: "password";
|
||||
cipherId: string;
|
||||
uri: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export type NativeAutofillSyncResult = CommandOutput<{
|
||||
added: number;
|
||||
}>;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { crypto } from "@bitwarden/desktop-napi";
|
||||
import { NodeCryptoFunctionService } from "@bitwarden/node/services/node-crypto-function.service";
|
||||
|
||||
export class MainCryptoFunctionService
|
||||
@@ -13,16 +14,16 @@ export class MainCryptoFunctionService
|
||||
async (
|
||||
event,
|
||||
opts: {
|
||||
password: string | Uint8Array;
|
||||
salt: string | Uint8Array;
|
||||
password: Uint8Array;
|
||||
salt: Uint8Array;
|
||||
iterations: number;
|
||||
memory: number;
|
||||
parallelism: number;
|
||||
},
|
||||
) => {
|
||||
return await this.argon2(
|
||||
opts.password,
|
||||
opts.salt,
|
||||
return await crypto.argon2(
|
||||
Buffer.from(opts.password),
|
||||
Buffer.from(opts.salt),
|
||||
opts.iterations,
|
||||
opts.memory,
|
||||
opts.parallelism,
|
||||
|
||||
@@ -99,8 +99,8 @@ const nativeMessaging = {
|
||||
|
||||
const crypto = {
|
||||
argon2: (
|
||||
password: string | Uint8Array,
|
||||
salt: string | Uint8Array,
|
||||
password: Uint8Array,
|
||||
salt: Uint8Array,
|
||||
iterations: number,
|
||||
memory: number,
|
||||
parallelism: number,
|
||||
|
||||
@@ -198,7 +198,10 @@ export class SshAgentService implements OnDestroy {
|
||||
}
|
||||
|
||||
const sshCiphers = ciphers.filter(
|
||||
(cipher) => cipher.type === CipherType.SshKey && !cipher.isDeleted,
|
||||
(cipher) =>
|
||||
cipher.type === CipherType.SshKey &&
|
||||
!cipher.isDeleted &&
|
||||
cipher.organizationId === null,
|
||||
);
|
||||
const keys = sshCiphers.map((cipher) => {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { contextBridge } from "electron";
|
||||
|
||||
import auth from "./auth/preload";
|
||||
import autofill from "./autofill/preload";
|
||||
import keyManagement from "./key-management/preload";
|
||||
import platform from "./platform/preload";
|
||||
|
||||
@@ -17,6 +18,7 @@ import platform from "./platform/preload";
|
||||
// Each team owns a subspace of the `ipc` global variable in the renderer.
|
||||
export const ipc = {
|
||||
auth,
|
||||
autofill,
|
||||
platform,
|
||||
keyManagement,
|
||||
};
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
<li
|
||||
class="filter-option"
|
||||
[ngClass]="{ active: activeFilter.cipherType === cipherTypeEnum.SshKey }"
|
||||
*ngIf="isSshKeysEnabled"
|
||||
>
|
||||
<span class="filter-buttons">
|
||||
<button
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
|
||||
import { TypeFilterComponent as BaseTypeFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/type-filter.component";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
@Component({
|
||||
selector: "app-type-filter",
|
||||
templateUrl: "type-filter.component.html",
|
||||
})
|
||||
export class TypeFilterComponent extends BaseTypeFilterComponent {}
|
||||
export class TypeFilterComponent extends BaseTypeFilterComponent implements OnInit {
|
||||
isSshKeysEnabled = false;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.isSshKeysEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,8 +81,6 @@ const main = {
|
||||
externals: {
|
||||
"electron-reload": "commonjs2 electron-reload",
|
||||
"@bitwarden/desktop-napi": "commonjs2 @bitwarden/desktop-napi",
|
||||
|
||||
argon2: "commonjs2 argon2",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -59,8 +59,14 @@ describe("Is Enterprise Org Guard", () => {
|
||||
{
|
||||
path: "organizations/:organizationId/enterpriseOrgsOnly",
|
||||
component: IsEnterpriseOrganizationComponent,
|
||||
canActivate: [isEnterpriseOrgGuard()],
|
||||
canActivate: [isEnterpriseOrgGuard(true)],
|
||||
},
|
||||
{
|
||||
path: "organizations/:organizationId/enterpriseOrgsOnlyNoError",
|
||||
component: IsEnterpriseOrganizationComponent,
|
||||
canActivate: [isEnterpriseOrgGuard(false)],
|
||||
},
|
||||
|
||||
{
|
||||
path: "organizations/:organizationId/billing/subscription",
|
||||
component: OrganizationUpgradeScreenComponent,
|
||||
@@ -115,6 +121,24 @@ describe("Is Enterprise Org Guard", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
ProductTierType.Free,
|
||||
ProductTierType.Families,
|
||||
ProductTierType.Teams,
|
||||
ProductTierType.TeamsStarter,
|
||||
])("does not proceed with the navigation for productTierType '%s'", async (productTierType) => {
|
||||
const org = orgFactory({
|
||||
type: OrganizationUserType.User,
|
||||
productTierType: productTierType,
|
||||
});
|
||||
organizationService.get.calledWith(org.id).mockResolvedValue(org);
|
||||
await routerHarness.navigateByUrl(`organizations/${org.id}/enterpriseOrgsOnlyNoError`);
|
||||
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
expect(
|
||||
routerHarness.routeNativeElement?.querySelector("h1")?.textContent?.trim() ?? "",
|
||||
).not.toBe("This component can only be accessed by a enterprise organization!");
|
||||
});
|
||||
|
||||
it("proceeds with navigation if the organization in question is a enterprise organization", async () => {
|
||||
const org = orgFactory({ productTierType: ProductTierType.Enterprise });
|
||||
organizationService.get.calledWith(org.id).mockResolvedValue(org);
|
||||
|
||||
@@ -17,7 +17,7 @@ import { DialogService } from "@bitwarden/components";
|
||||
* if they have access to upgrade the organization. If the organization is
|
||||
* enterprise routing proceeds."
|
||||
*/
|
||||
export function isEnterpriseOrgGuard(): CanActivateFn {
|
||||
export function isEnterpriseOrgGuard(showError: boolean = true): CanActivateFn {
|
||||
return async (route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
|
||||
const router = inject(Router);
|
||||
const organizationService = inject(OrganizationService);
|
||||
@@ -29,7 +29,7 @@ export function isEnterpriseOrgGuard(): CanActivateFn {
|
||||
return router.createUrlTree(["/"]);
|
||||
}
|
||||
|
||||
if (org.productTierType != ProductTierType.Enterprise) {
|
||||
if (org.productTierType != ProductTierType.Enterprise && showError) {
|
||||
// Users without billing permission can't access billing
|
||||
if (!org.canEditSubscription) {
|
||||
await dialogService.openSimpleDialog({
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<app-header> </app-header>
|
||||
|
||||
<bit-tab-group [(selectedIndex)]="tabIndex">
|
||||
<bit-tab [label]="'singleSignOn' | i18n">
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">{{ "singleSignOn" | i18n }}</h2>
|
||||
<p bitTypography="body1">
|
||||
{{ "ssoDescStart" | i18n }}
|
||||
<a bitLink routerLink="../settings/sso" class="tw-lowercase">{{ "singleSignOn" | i18n }}</a>
|
||||
{{ "ssoDescEnd" | i18n }}
|
||||
</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.SSO"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
</bit-tab>
|
||||
|
||||
<bit-tab [label]="'userProvisioning' | i18n">
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "scimIntegration" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">
|
||||
{{ "scimIntegrationDescStart" | i18n }}
|
||||
<a bitLink routerLink="../settings/scim">{{ "scimIntegration" | i18n }}</a>
|
||||
{{ "scimIntegrationDescEnd" | i18n }}
|
||||
</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.SCIM"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "bwdc" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">{{ "bwdcDesc" | i18n }}</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.BWDC"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
</bit-tab>
|
||||
|
||||
<bit-tab [label]="'eventManagement' | i18n">
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "eventManagement" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">{{ "eventManagementDesc" | i18n }}</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.EVENT"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
</bit-tab>
|
||||
|
||||
<bit-tab [label]="'deviceManagement' | i18n">
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "deviceManagement" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">{{ "deviceManagementDesc" | i18n }}</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.DEVICE"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
</bit-tab>
|
||||
</bit-tab-group>
|
||||
@@ -0,0 +1,207 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { IntegrationType } from "@bitwarden/common/enums";
|
||||
|
||||
import { HeaderModule } from "../../../layouts/header/header.module";
|
||||
import { FilterIntegrationsPipe, IntegrationGridComponent, Integration } from "../../../shared/";
|
||||
import { SharedModule } from "../../../shared/shared.module";
|
||||
import { SharedOrganizationModule } from "../shared";
|
||||
|
||||
@Component({
|
||||
selector: "ac-integrations",
|
||||
templateUrl: "./integrations.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
SharedModule,
|
||||
SharedOrganizationModule,
|
||||
IntegrationGridComponent,
|
||||
HeaderModule,
|
||||
FilterIntegrationsPipe,
|
||||
],
|
||||
})
|
||||
export class AdminConsoleIntegrationsComponent {
|
||||
integrationsList: Integration[] = [];
|
||||
tabIndex: number;
|
||||
|
||||
constructor() {
|
||||
this.integrationsList = [
|
||||
{
|
||||
name: "AD FS",
|
||||
linkURL: "https://bitwarden.com/help/saml-adfs/",
|
||||
image: "../../../../../../../images/integrations/azure-active-directory.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "Auth0",
|
||||
linkURL: "https://bitwarden.com/help/saml-auth0/",
|
||||
image: "../../../../../../../images/integrations/logo-auth0-badge-color.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "AWS",
|
||||
linkURL: "https://bitwarden.com/help/saml-aws/",
|
||||
image: "../../../../../../../images/integrations/aws-color.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/aws-darkmode.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "Microsoft Entra ID",
|
||||
linkURL: "https://bitwarden.com/help/saml-azure/",
|
||||
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "Duo",
|
||||
linkURL: "https://bitwarden.com/help/saml-duo/",
|
||||
image: "../../../../../../../images/integrations/logo-duo-color.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "Google",
|
||||
linkURL: "https://bitwarden.com/help/saml-google/",
|
||||
image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "JumpCloud",
|
||||
linkURL: "https://bitwarden.com/help/saml-jumpcloud/",
|
||||
image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "KeyCloak",
|
||||
linkURL: "https://bitwarden.com/help/saml-keycloak/",
|
||||
image: "../../../../../../../images/integrations/logo-keycloak-icon.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "Okta",
|
||||
linkURL: "https://bitwarden.com/help/saml-okta/",
|
||||
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "OneLogin",
|
||||
linkURL: "https://bitwarden.com/help/saml-onelogin/",
|
||||
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "PingFederate",
|
||||
linkURL: "https://bitwarden.com/help/saml-pingfederate/",
|
||||
image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "Microsoft Entra ID",
|
||||
linkURL: "https://bitwarden.com/help/microsoft-entra-id-scim-integration/",
|
||||
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
|
||||
type: IntegrationType.SCIM,
|
||||
},
|
||||
{
|
||||
name: "Okta",
|
||||
linkURL: "https://bitwarden.com/help/okta-scim-integration/",
|
||||
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
|
||||
type: IntegrationType.SCIM,
|
||||
},
|
||||
{
|
||||
name: "OneLogin",
|
||||
linkURL: "https://bitwarden.com/help/onelogin-scim-integration/",
|
||||
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
|
||||
type: IntegrationType.SCIM,
|
||||
},
|
||||
{
|
||||
name: "JumpCloud",
|
||||
linkURL: "https://bitwarden.com/help/jumpcloud-scim-integration/",
|
||||
image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
|
||||
type: IntegrationType.SCIM,
|
||||
},
|
||||
{
|
||||
name: "Ping Identity",
|
||||
linkURL: "https://bitwarden.com/help/ping-identity-scim-integration/",
|
||||
image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
|
||||
type: IntegrationType.SCIM,
|
||||
},
|
||||
{
|
||||
name: "Active Directory",
|
||||
linkURL: "https://bitwarden.com/help/ldap-directory/",
|
||||
image: "../../../../../../../images/integrations/azure-active-directory.svg",
|
||||
type: IntegrationType.BWDC,
|
||||
},
|
||||
{
|
||||
name: "Microsoft Entra ID",
|
||||
linkURL: "https://bitwarden.com/help/microsoft-entra-id/",
|
||||
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
|
||||
type: IntegrationType.BWDC,
|
||||
},
|
||||
{
|
||||
name: "Google Workspace",
|
||||
linkURL: "https://bitwarden.com/help/workspace-directory/",
|
||||
image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
|
||||
type: IntegrationType.BWDC,
|
||||
},
|
||||
{
|
||||
name: "Okta",
|
||||
linkURL: "https://bitwarden.com/help/okta-directory/",
|
||||
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
|
||||
type: IntegrationType.BWDC,
|
||||
},
|
||||
{
|
||||
name: "OneLogin",
|
||||
linkURL: "https://bitwarden.com/help/onelogin-directory/",
|
||||
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
|
||||
type: IntegrationType.BWDC,
|
||||
},
|
||||
{
|
||||
name: "Splunk",
|
||||
linkURL: "https://bitwarden.com/help/splunk-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-splunk-black.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/splunk-darkmode.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
},
|
||||
{
|
||||
name: "Microsoft Sentinel",
|
||||
linkURL: "https://bitwarden.com/help/microsoft-sentinel-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-microsoft-sentinel-color.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
},
|
||||
{
|
||||
name: "Rapid7",
|
||||
linkURL: "https://bitwarden.com/help/rapid7-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-rapid7-black.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/rapid7-darkmode.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
},
|
||||
{
|
||||
name: "Elastic",
|
||||
linkURL: "https://bitwarden.com/help/elastic-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-elastic-badge-color.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
},
|
||||
{
|
||||
name: "Panther",
|
||||
linkURL: "https://bitwarden.com/help/panther-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-panther-round-color.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
},
|
||||
{
|
||||
name: "Microsoft Intune",
|
||||
linkURL: "https://bitwarden.com/help/deploy-browser-extensions-with-intune/",
|
||||
image: "../../../../../../../images/integrations/logo-microsoft-intune-color.svg",
|
||||
type: IntegrationType.DEVICE,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
get IntegrationType(): typeof IntegrationType {
|
||||
return IntegrationType;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
<org-switcher [filter]="orgFilter" [hideNewButton]="hideNewOrgButton$ | async"></org-switcher>
|
||||
<bit-nav-group
|
||||
icon="bwi-filter"
|
||||
*ngIf="isAccessIntelligenceFeatureEnabled"
|
||||
*ngIf="organization.useRiskInsights"
|
||||
[text]="'accessIntelligence' | i18n"
|
||||
>
|
||||
<bit-nav-item
|
||||
@@ -60,6 +60,12 @@
|
||||
<bit-nav-item [text]="'billingHistory' | i18n" route="billing/history"></bit-nav-item>
|
||||
</ng-container>
|
||||
</bit-nav-group>
|
||||
<bit-nav-item
|
||||
icon="bwi-providers"
|
||||
[text]="'integrations' | i18n"
|
||||
route="integrations"
|
||||
*ngIf="integrationPageEnabled$ | async"
|
||||
></bit-nav-item>
|
||||
<bit-nav-group
|
||||
icon="bwi-cog"
|
||||
[text]="'settings' | i18n"
|
||||
|
||||
@@ -18,6 +18,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||
import { PolicyType, ProviderStatusType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -47,12 +48,15 @@ export class OrganizationLayoutComponent implements OnInit {
|
||||
|
||||
protected orgFilter = (org: Organization) => canAccessOrgAdmin(org);
|
||||
|
||||
protected integrationPageEnabled$: Observable<boolean>;
|
||||
|
||||
organization$: Observable<Organization>;
|
||||
canAccessExport$: Observable<boolean>;
|
||||
showPaymentAndHistory$: Observable<boolean>;
|
||||
hideNewOrgButton$: Observable<boolean>;
|
||||
organizationIsUnmanaged$: Observable<boolean>;
|
||||
isAccessIntelligenceFeatureEnabled = false;
|
||||
enterpriseOrganization$: Observable<boolean>;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@@ -66,10 +70,6 @@ export class OrganizationLayoutComponent implements OnInit {
|
||||
async ngOnInit() {
|
||||
document.body.classList.remove("layout_frontend");
|
||||
|
||||
this.isAccessIntelligenceFeatureEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.AccessIntelligence,
|
||||
);
|
||||
|
||||
this.organization$ = this.route.params.pipe(
|
||||
map((p) => p.organizationId),
|
||||
switchMap((id) => this.organizationService.organizations$.pipe(getById(id))),
|
||||
@@ -104,6 +104,16 @@ export class OrganizationLayoutComponent implements OnInit {
|
||||
provider.providerStatus !== ProviderStatusType.Billable,
|
||||
),
|
||||
);
|
||||
|
||||
this.integrationPageEnabled$ = combineLatest(
|
||||
this.organization$,
|
||||
this.configService.getFeatureFlag$(FeatureFlag.PM14505AdminConsoleIntegrationPage),
|
||||
).pipe(
|
||||
map(
|
||||
([org, featureFlagEnabled]) =>
|
||||
org.productTierType === ProductTierType.Enterprise && featureFlagEnabled,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
canShowVaultTab(organization: Organization): boolean {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</bit-callout>
|
||||
<ng-container *ngIf="!done">
|
||||
<bit-callout type="warning" *ngIf="users.length > 0 && !error">
|
||||
<p bitTypography="body1">{{ "deleteOrganizationUserWarning" | i18n }}</p>
|
||||
<p bitTypography="body1">{{ "deleteManyOrganizationUsersWarningDesc" | i18n }}</p>
|
||||
</bit-callout>
|
||||
<bit-table>
|
||||
<ng-container header>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<bit-dialog dialogSize="large" [title]="'removeUsers' | i18n">
|
||||
<bit-dialog dialogSize="large" [title]="'removeMembers' | i18n">
|
||||
<ng-container bitDialogContent>
|
||||
<bit-callout type="danger" *ngIf="users.length <= 0">
|
||||
{{ "noSelectedUsersApplicable" | i18n }}
|
||||
@@ -79,7 +79,7 @@
|
||||
[disabled]="loading"
|
||||
[bitAction]="submit"
|
||||
>
|
||||
{{ "removeUsers" | i18n }}
|
||||
{{ "removeMembers" | i18n }}
|
||||
</button>
|
||||
<button bitButton type="button" buttonType="secondary" bitDialogClose>
|
||||
{{ "close" | i18n }}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<bit-dialog>
|
||||
<bit-dialog
|
||||
dialogSize="large"
|
||||
*ngIf="{ enabled: accountDeprovisioningEnabled$ | async } as accountDeprovisioning"
|
||||
>
|
||||
<ng-container bitDialogTitle>
|
||||
<h1>{{ bulkTitle }}</h1>
|
||||
<h1 *ngIf="accountDeprovisioning.enabled; else nonMemberTitle">{{ bulkMemberTitle }}</h1>
|
||||
<ng-template #nonMemberTitle>
|
||||
<h1>{{ bulkTitle }}</h1>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<div bitDialogContent>
|
||||
<bit-callout type="danger" *ngIf="users.length <= 0">
|
||||
{{ "noSelectedUsersApplicable" | i18n }}
|
||||
@@ -11,15 +18,81 @@
|
||||
{{ error }}
|
||||
</bit-callout>
|
||||
|
||||
<ng-container *ngIf="!done">
|
||||
<bit-callout type="warning" *ngIf="users.length > 0 && !error && isRevoking">
|
||||
<p>{{ "revokeUsersWarning" | i18n }}</p>
|
||||
<p *ngIf="this.showNoMasterPasswordWarning">
|
||||
{{ "removeMembersWithoutMasterPasswordWarning" | i18n }}
|
||||
</p>
|
||||
</bit-callout>
|
||||
<bit-callout
|
||||
type="danger"
|
||||
*ngIf="nonCompliantMembers && accountDeprovisioning.enabled"
|
||||
title="{{ 'nonCompliantMembersTitle' | i18n }}"
|
||||
>
|
||||
{{ "nonCompliantMembersError" | i18n }}
|
||||
</bit-callout>
|
||||
|
||||
<bit-table>
|
||||
<ng-container *ngIf="!done">
|
||||
<ng-container *ngIf="accountDeprovisioning.enabled">
|
||||
<div *ngIf="users.length > 0 && !error && isRevoking">
|
||||
<p>{{ "revokeMembersWarning" | i18n }}</p>
|
||||
<ul>
|
||||
<li>
|
||||
{{ "claimedAccountRevoke" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
{{ "unclaimedAccountRevoke" | i18n }}
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
{{ "restoreMembersInstructions" | i18n }}
|
||||
</p>
|
||||
<p *ngIf="this.showNoMasterPasswordWarning">
|
||||
{{ "removeMembersWithoutMasterPasswordWarning" | i18n }}
|
||||
</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!accountDeprovisioning.enabled">
|
||||
<bit-callout type="warning" *ngIf="users.length > 0 && !error && isRevoking">
|
||||
<p>{{ "revokeUsersWarning" | i18n }}</p>
|
||||
</bit-callout>
|
||||
</ng-container>
|
||||
|
||||
<bit-table *ngIf="accountDeprovisioning.enabled">
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th bitCell class="tw-w-1/2">{{ "member" | i18n }}</th>
|
||||
<th bitCell *ngIf="isRevoking">{{ "details" | i18n }}</th>
|
||||
<th bitCell *ngIf="this.showNoMasterPasswordWarning">{{ "details" | i18n }}</th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
<ng-template body>
|
||||
<tr bitRow *ngFor="let user of users">
|
||||
<td bitCell width="30">
|
||||
<div class="tw-flex tw-items-center">
|
||||
<div class="tw-flex tw-items-center tw-mr-6">
|
||||
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-avatar>
|
||||
</div>
|
||||
<div>
|
||||
{{ user.email }}
|
||||
<small class="tw-block tw-text-muted" *ngIf="user.name">{{ user.name }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td *ngIf="isRevoking" bitCell width="30">
|
||||
{{
|
||||
user.managedByOrganization ? ("claimedAccount" | i18n) : ("unclaimedAccount" | i18n)
|
||||
}}
|
||||
</td>
|
||||
<td bitCell *ngIf="this.showNoMasterPasswordWarning">
|
||||
<span class="tw-block tw-lowercase tw-text-muted">
|
||||
<ng-container *ngIf="user.hasMasterPassword === true"> - </ng-container>
|
||||
<ng-container *ngIf="user.hasMasterPassword === false">
|
||||
<i class="bwi bwi-exclamation-triangle" aria-hidden="true"></i>
|
||||
{{ "noMasterPassword" | i18n }}
|
||||
</ng-container>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
|
||||
<bit-table *ngIf="!accountDeprovisioning.enabled">
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th bitCell colspan="2">{{ "user" | i18n }}</th>
|
||||
@@ -50,21 +123,55 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="done">
|
||||
<bit-table>
|
||||
<bit-table *ngIf="accountDeprovisioning.enabled">
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th bitCell colspan="2">{{ "user" | i18n }}</th>
|
||||
<th>{{ "status" | i18n }}</th>
|
||||
<th bitCell class="tw-w-1/2">{{ "member" | i18n }}</th>
|
||||
<th bitCell>{{ "status" | i18n }}</th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
<ng-template body>
|
||||
<tr bitRow *ngFor="let user of users">
|
||||
<td bitCell width="30">
|
||||
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-avatar>
|
||||
<div class="tw-flex tw-items-center">
|
||||
<div class="tw-flex tw-items-center tw-mr-6">
|
||||
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-avatar>
|
||||
</div>
|
||||
<div>
|
||||
{{ user.email }}
|
||||
<small class="tw-block tw-text-muted" *ngIf="user.name">{{ user.name }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td bitCell>
|
||||
{{ user.email }}
|
||||
<small class="tw-block tw-text-muted" *ngIf="user.name">{{ user.name }}</small>
|
||||
<td bitCell *ngIf="statuses.has(user.id)">
|
||||
{{ statuses.get(user.id) }}
|
||||
</td>
|
||||
<td bitCell *ngIf="!statuses.has(user.id)">
|
||||
{{ "bulkFilteredMessage" | i18n }}
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
|
||||
<bit-table *ngIf="!accountDeprovisioning.enabled">
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th bitCell class="tw-w-1/2">{{ "member" | i18n }}</th>
|
||||
<th bitCell>{{ "status" | i18n }}</th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
<ng-template body>
|
||||
<tr bitRow *ngFor="let user of users">
|
||||
<td bitCell width="30">
|
||||
<div class="tw-flex tw-items-center">
|
||||
<div class="tw-flex tw-items-center tw-mr-6">
|
||||
<bit-avatar [text]="user | userName" [id]="user.id" size="small"></bit-avatar>
|
||||
</div>
|
||||
<div>
|
||||
{{ user.email }}
|
||||
<small class="tw-block tw-text-muted" *ngIf="user.name">{{ user.name }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td bitCell *ngIf="statuses.has(user.id)">
|
||||
{{ statuses.get(user.id) }}
|
||||
@@ -79,7 +186,7 @@
|
||||
</div>
|
||||
<ng-container bitDialogFooter>
|
||||
<button type="button" bitButton *ngIf="!done && users.length > 0" [bitAction]="submit">
|
||||
{{ bulkTitle }}
|
||||
{{ accountDeprovisioning.enabled ? bulkMemberTitle : bulkTitle }}
|
||||
</button>
|
||||
<button type="button" bitButton buttonType="secondary" bitDialogClose>
|
||||
{{ "close" | i18n }}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
import { Component, Inject } from "@angular/core";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
|
||||
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
@@ -29,10 +32,13 @@ export class BulkRestoreRevokeComponent {
|
||||
done = false;
|
||||
error: string;
|
||||
showNoMasterPasswordWarning = false;
|
||||
nonCompliantMembers: boolean = false;
|
||||
accountDeprovisioningEnabled$: Observable<boolean>;
|
||||
|
||||
constructor(
|
||||
protected i18nService: I18nService,
|
||||
private organizationUserApiService: OrganizationUserApiService,
|
||||
private configService: ConfigService,
|
||||
@Inject(DIALOG_DATA) protected data: BulkRestoreDialogParams,
|
||||
) {
|
||||
this.isRevoking = data.isRevoking;
|
||||
@@ -41,6 +47,9 @@ export class BulkRestoreRevokeComponent {
|
||||
this.showNoMasterPasswordWarning = this.users.some(
|
||||
(u) => u.status > OrganizationUserStatusType.Invited && u.hasMasterPassword === false,
|
||||
);
|
||||
this.accountDeprovisioningEnabled$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.AccountDeprovisioning,
|
||||
);
|
||||
}
|
||||
|
||||
get bulkTitle() {
|
||||
@@ -48,14 +57,26 @@ export class BulkRestoreRevokeComponent {
|
||||
return this.i18nService.t(titleKey);
|
||||
}
|
||||
|
||||
get bulkMemberTitle() {
|
||||
const titleKey = this.isRevoking ? "revokeMembers" : "restoreMembers";
|
||||
return this.i18nService.t(titleKey);
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
try {
|
||||
const response = await this.performBulkUserAction();
|
||||
|
||||
const bulkMessage = this.isRevoking ? "bulkRevokedMessage" : "bulkRestoredMessage";
|
||||
response.data.forEach((entry) => {
|
||||
const error = entry.error !== "" ? entry.error : this.i18nService.t(bulkMessage);
|
||||
|
||||
response.data.forEach(async (entry) => {
|
||||
const error =
|
||||
entry.error !== ""
|
||||
? this.i18nService.t("cannotRestoreAccessError")
|
||||
: this.i18nService.t(bulkMessage);
|
||||
this.statuses.set(entry.id, error);
|
||||
if (entry.error !== "") {
|
||||
this.nonCompliantMembers = true;
|
||||
}
|
||||
});
|
||||
this.done = true;
|
||||
} catch (e) {
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface BulkUserDetails {
|
||||
email: string;
|
||||
status: OrganizationUserStatusType | ProviderUserStatusType;
|
||||
hasMasterPassword?: boolean;
|
||||
managedByOrganization?: boolean;
|
||||
}
|
||||
|
||||
type BulkStatusEntry = {
|
||||
|
||||
@@ -579,7 +579,10 @@ export class MemberDialogComponent implements OnDestroy {
|
||||
key: "deleteOrganizationUser",
|
||||
placeholders: [this.params.name],
|
||||
},
|
||||
content: { key: "deleteOrganizationUserWarning" },
|
||||
content: {
|
||||
key: "deleteOrganizationUserWarningDesc",
|
||||
placeholders: [this.params.name],
|
||||
},
|
||||
type: "warning",
|
||||
acceptButtonText: { key: "delete" },
|
||||
cancelButtonText: { key: "cancel" },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user