Merge branch 'main' into autofill/pm-12316-implement-inline-menu-passkeys-loading-state

This commit is contained in:
Cesar Gonzalez 2024-09-20 15:28:21 -05:00 committed by GitHub
commit 090daeeb48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 292 additions and 469 deletions

View File

@ -5,7 +5,7 @@
</ng-container>
</popup-header>
<div class="tw-bg-background-alt tw-p-2">
<div class="tw-bg-background-alt">
<bit-section>
<bit-section-header>
<h2 bitTypography="h6">{{ "autofillSuggestionsSectionTitle" | i18n }}</h2>
@ -107,7 +107,7 @@
</bit-section-header>
<bit-item>
<button bit-item-content type="button" (click)="openURI($event, browserShortcutsURI)">
<h3 bitTypography="h5">{{ "autofillKeyboardManagerShortcutsLabel" | i18n }}</h3>
<h3 bitTypography="body2">{{ "autofillKeyboardManagerShortcutsLabel" | i18n }}</h3>
<bit-hint slot="secondary" class="tw-text-sm tw-whitespace-normal">
{{ autofillKeyboardHelperText }}
</bit-hint>

View File

@ -5,7 +5,7 @@
</ng-container>
</popup-header>
<div class="tw-bg-background-alt tw-p-2">
<div class="tw-bg-background-alt">
<p>
{{
accountSwitcherEnabled ? ("excludedDomainsDescAlt" | i18n) : ("excludedDomainsDesc" | i18n)

View File

@ -5,7 +5,7 @@
</ng-container>
</popup-header>
<div class="tw-bg-background-alt tw-p-2">
<div class="tw-bg-background-alt">
<bit-section>
<bit-section-header>
<h2 bitTypography="h6">{{ "vaultSaveOptionsTitle" | i18n }}</h2>

View File

@ -1484,14 +1484,7 @@ export default class MainBackground {
});
if (needStorageReseed) {
await this.reseedStorage(
await firstValueFrom(
this.configService.userCachedFeatureFlag$(
FeatureFlag.StorageReseedRefactor,
userBeingLoggedOut,
),
),
);
await this.reseedStorage();
}
if (BrowserApi.isManifestVersion(3)) {
@ -1546,7 +1539,7 @@ export default class MainBackground {
await SafariApp.sendMessageToApp("showPopover", null, true);
}
async reseedStorage(doFillBuffer: boolean) {
async reseedStorage() {
if (
!this.platformUtilsService.isChrome() &&
!this.platformUtilsService.isVivaldi() &&
@ -1555,11 +1548,7 @@ export default class MainBackground {
return;
}
if (doFillBuffer) {
await this.storageService.fillBuffer();
} else {
await this.storageService.reseed();
}
await this.storageService.fillBuffer();
}
async clearClipboard(clipboardValue: string, clearMs: number) {

View File

@ -1,4 +1,4 @@
import { firstValueFrom, map, mergeMap, of, switchMap } from "rxjs";
import { firstValueFrom, map, mergeMap } from "rxjs";
import { LockService } from "@bitwarden/auth/common";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
@ -281,22 +281,7 @@ export default class RuntimeBackground {
await this.main.refreshMenu();
break;
case "bgReseedStorage": {
const doFillBuffer = await firstValueFrom(
this.accountService.activeAccount$.pipe(
switchMap((account) => {
if (account == null) {
return of(false);
}
return this.configService.userCachedFeatureFlag$(
FeatureFlag.StorageReseedRefactor,
account.id,
);
}),
),
);
await this.main.reseedStorage(doFillBuffer);
await this.main.reseedStorage();
break;
}
case "authResult": {

View File

@ -1,192 +0,0 @@
import { objToStore } from "./abstractions/abstract-chrome-storage-api.service";
import BrowserLocalStorageService, {
RESEED_IN_PROGRESS_KEY,
} from "./browser-local-storage.service";
const apiGetLike =
(store: Record<any, any>) => (key: string, callback: (items: { [key: string]: any }) => void) => {
if (key == null) {
callback(store);
} else {
callback({ [key]: store[key] });
}
};
describe("BrowserLocalStorageService", () => {
let service: BrowserLocalStorageService;
let store: Record<any, any>;
let changeListener: (changes: { [key: string]: chrome.storage.StorageChange }) => void;
let saveMock: jest.Mock;
let getMock: jest.Mock;
let clearMock: jest.Mock;
let removeMock: jest.Mock;
beforeEach(() => {
store = {};
// Record change listener
chrome.storage.local.onChanged.addListener = jest.fn((listener) => {
changeListener = listener;
});
service = new BrowserLocalStorageService();
// setup mocks
getMock = chrome.storage.local.get as jest.Mock;
getMock.mockImplementation(apiGetLike(store));
saveMock = chrome.storage.local.set as jest.Mock;
saveMock.mockImplementation((update, callback) => {
Object.entries(update).forEach(([key, value]) => {
store[key] = value;
});
callback();
});
clearMock = chrome.storage.local.clear as jest.Mock;
clearMock.mockImplementation((callback) => {
store = {};
callback?.();
});
removeMock = chrome.storage.local.remove as jest.Mock;
removeMock.mockImplementation((keys, callback) => {
if (Array.isArray(keys)) {
keys.forEach((key) => {
delete store[key];
});
} else {
delete store[keys];
}
callback();
});
});
afterEach(() => {
chrome.runtime.lastError = undefined;
jest.resetAllMocks();
});
describe("reseed", () => {
it.each([
{
key1: objToStore("value1"),
key2: objToStore("value2"),
key3: null,
},
{},
])("saves all data in storage %s", async (testStore) => {
for (const key of Object.keys(testStore) as Array<keyof typeof testStore>) {
store[key] = testStore[key];
}
await service.reseed();
expect(saveMock).toHaveBeenLastCalledWith(
{ ...testStore, [RESEED_IN_PROGRESS_KEY]: objToStore(true) },
expect.any(Function),
);
});
it.each([
{
key1: objToStore("value1"),
key2: objToStore("value2"),
key3: null,
},
{},
])("results in the same store %s", async (testStore) => {
for (const key of Object.keys(testStore) as Array<keyof typeof testStore>) {
store[key] = testStore[key];
}
await service.reseed();
expect(store).toEqual(testStore);
});
it("converts non-serialized values to serialized", async () => {
store.key1 = "value1";
store.key2 = "value2";
const expectedStore = {
key1: objToStore("value1"),
key2: objToStore("value2"),
reseedInProgress: objToStore(true),
};
await service.reseed();
expect(saveMock).toHaveBeenLastCalledWith(expectedStore, expect.any(Function));
});
it("clears data", async () => {
await service.reseed();
expect(clearMock).toHaveBeenCalledTimes(1);
});
it("throws if get has chrome.runtime.lastError", async () => {
getMock.mockImplementation((key, callback) => {
chrome.runtime.lastError = new Error("Get Test Error");
callback();
});
await expect(async () => await service.reseed()).rejects.toThrow("Get Test Error");
});
it("throws if save has chrome.runtime.lastError", async () => {
saveMock.mockImplementation((obj, callback) => {
chrome.runtime.lastError = new Error("Save Test Error");
callback();
});
await expect(async () => await service.reseed()).rejects.toThrow("Save Test Error");
});
});
describe.each(["get", "has", "save", "remove"] as const)("%s", (method) => {
let interval: string | number | NodeJS.Timeout;
afterEach(() => {
if (interval) {
clearInterval(interval);
}
});
function startReseed() {
store[RESEED_IN_PROGRESS_KEY] = objToStore(true);
}
function endReseed() {
delete store[RESEED_IN_PROGRESS_KEY];
changeListener({ reseedInProgress: { oldValue: true } });
}
it("waits for reseed prior to operation", async () => {
startReseed();
const promise = service[method]("key", "value"); // note "value" is only used in save, but ignored in other methods
await expect(promise).not.toBeFulfilled(10);
endReseed();
await expect(promise).toBeResolved();
});
it("does not wait if reseed is not in progress", async () => {
const promise = service[method]("key", "value");
await expect(promise).toBeResolved(1);
});
it("awaits prior reseed operations before starting a new one", async () => {
startReseed();
const promise = service.reseed();
await expect(promise).not.toBeFulfilled(10);
endReseed();
await expect(promise).toBeResolved();
});
});
});

View File

@ -1,35 +1,8 @@
import { defer, filter, firstValueFrom, map, merge, throwError, timeout } from "rxjs";
import AbstractChromeStorageService, {
SerializedValue,
objToStore,
} from "./abstractions/abstract-chrome-storage-api.service";
export const RESEED_IN_PROGRESS_KEY = "reseedInProgress";
import AbstractChromeStorageService from "./abstractions/abstract-chrome-storage-api.service";
export default class BrowserLocalStorageService extends AbstractChromeStorageService {
constructor() {
super(chrome.storage.local);
this.chromeStorageApi.remove(RESEED_IN_PROGRESS_KEY, () => {
return;
});
}
/**
* Reads, clears, and re-saves all data in local storage. This is a hack to remove previously stored sensitive data from
* local storage logs.
*
* @see https://github.com/bitwarden/clients/issues/485
*/
async reseed(): Promise<void> {
try {
await this.save(RESEED_IN_PROGRESS_KEY, true);
const data = await this.getAll();
await this.clear();
await this.saveAll(data);
} finally {
await super.remove(RESEED_IN_PROGRESS_KEY);
}
}
async fillBuffer() {
@ -71,107 +44,4 @@ export default class BrowserLocalStorageService extends AbstractChromeStorageSer
);
});
}
override async get<T>(key: string): Promise<T> {
await this.awaitReseed();
return super.get(key);
}
override async has(key: string): Promise<boolean> {
await this.awaitReseed();
return super.has(key);
}
override async save(key: string, obj: any): Promise<void> {
await this.awaitReseed();
return super.save(key, obj);
}
override async remove(key: string): Promise<void> {
await this.awaitReseed();
return super.remove(key);
}
private async awaitReseed(): Promise<void> {
const notReseeding = async () => {
return !(await super.get(RESEED_IN_PROGRESS_KEY));
};
const finishedReseeding = this.updates$.pipe(
filter(({ key, updateType }) => key === RESEED_IN_PROGRESS_KEY && updateType === "remove"),
map(() => true),
);
await firstValueFrom(
merge(defer(notReseeding), finishedReseeding).pipe(
filter((v) => v),
timeout({
// We eventually need to give up and throw an error
first: 5_000,
with: () =>
throwError(
() => new Error("Reseeding local storage did not complete in a timely manner."),
),
}),
),
);
}
/**
* Clears local storage
*/
private async clear() {
return new Promise<void>((resolve, reject) => {
this.chromeStorageApi.clear(() => {
if (chrome.runtime.lastError) {
return reject(chrome.runtime.lastError);
}
resolve();
});
});
}
/**
* Retrieves all objects stored in local storage.
*
* @remarks This method processes values prior to resolving, do not use `chrome.storage.local` directly
* @returns Promise resolving to keyed object of all stored data
*/
private async getAll(): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
this.chromeStorageApi.get(null, (allStorage) => {
if (chrome.runtime.lastError) {
return reject(chrome.runtime.lastError);
}
const resolved = Object.entries(allStorage).reduce(
(agg, [key, value]) => {
agg[key] = this.processGetObject(value);
return agg;
},
{} as Record<string, unknown>,
);
resolve(resolved);
});
});
}
private async saveAll(data: Record<string, unknown>): Promise<void> {
return new Promise<void>((resolve, reject) => {
const keyedData = Object.entries(data).reduce(
(agg, [key, value]) => {
agg[key] = objToStore(value);
return agg;
},
{} as Record<string, SerializedValue>,
);
this.chromeStorageApi.set(keyedData, () => {
if (chrome.runtime.lastError) {
return reject(chrome.runtime.lastError);
}
resolve();
});
});
}
}

View File

@ -33,6 +33,7 @@ import { PopOutComponent } from "../../../../../platform/popup/components/pop-ou
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 { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service";
import { BrowserPremiumUpgradePromptService } from "../../../services/browser-premium-upgrade-prompt.service";
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
@ -77,6 +78,7 @@ export class ViewV2Component {
private vaultPopupAutofillService: VaultPopupAutofillService,
private accountService: AccountService,
private eventCollectionService: EventCollectionService,
private popupRouterCacheService: PopupRouterCacheService,
) {
this.subscribeToParams();
}
@ -163,8 +165,8 @@ export class ViewV2Component {
return false;
}
const successRoute = this.cipher.isDeleted ? "/trash" : "/vault";
await this.router.navigate([successRoute]);
await this.popupRouterCacheService.back();
this.toastService.showToast({
variant: "success",
title: null,
@ -181,7 +183,7 @@ export class ViewV2Component {
this.logService.error(e);
}
await this.router.navigate(["/trash"]);
await this.popupRouterCacheService.back();
this.toastService.showToast({
variant: "success",
title: null,

View File

@ -1,6 +1,6 @@
import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router";
import { ProductTierType, ProductType } from "@bitwarden/common/billing/enums";
import { ProductType } from "@bitwarden/common/billing/enums";
import { freeTrialTextResolver } from "./free-trial-text.resolver";
@ -11,48 +11,25 @@ const route = {
const routerStateSnapshot = {} as RouterStateSnapshot;
describe("freeTrialTextResolver", () => {
[
{
param: ProductType.PasswordManager,
keyBase: "startYour7DayFreeTrialOfBitwardenPasswordManager",
},
{
param: ProductType.SecretsManager,
keyBase: "startYour7DayFreeTrialOfBitwardenSecretsManager",
},
{
param: `${ProductType.PasswordManager},${ProductType.SecretsManager}`,
keyBase: "startYour7DayFreeTrialOfBitwarden",
},
].forEach(({ param, keyBase }) => {
describe(`when product is ${param}`, () => {
beforeEach(() => {
route.queryParams.product = `${param}`;
});
it("shows password manager text", () => {
route.queryParams.product = `${ProductType.PasswordManager}`;
it("returns teams trial text", () => {
route.queryParams.productTier = ProductTierType.Teams;
expect(freeTrialTextResolver(route, routerStateSnapshot)).toBe(
"continueSettingUpFreeTrialPasswordManager",
);
});
expect(freeTrialTextResolver(route, routerStateSnapshot)).toBe(`${keyBase}ForTeams`);
});
it("shows secret manager text", () => {
route.queryParams.product = `${ProductType.SecretsManager}`;
it("returns enterprise trial text", () => {
route.queryParams.productTier = ProductTierType.Enterprise;
expect(freeTrialTextResolver(route, routerStateSnapshot)).toBe(
"continueSettingUpFreeTrialSecretsManager",
);
});
expect(freeTrialTextResolver(route, routerStateSnapshot)).toBe(`${keyBase}ForEnterprise`);
});
it("shows default text", () => {
route.queryParams.product = `${ProductType.PasswordManager},${ProductType.SecretsManager}`;
it("returns families trial text", () => {
route.queryParams.productTier = ProductTierType.Families;
expect(freeTrialTextResolver(route, routerStateSnapshot)).toBe(`${keyBase}ForFamilies`);
});
it("returns default trial text", () => {
route.queryParams.productTier = "";
expect(freeTrialTextResolver(route, routerStateSnapshot)).toBe(keyBase);
});
});
expect(freeTrialTextResolver(route, routerStateSnapshot)).toBe("continueSettingUpFreeTrial");
});
});

View File

@ -1,43 +1,22 @@
import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router";
import { ProductType, ProductTierType } from "@bitwarden/common/billing/enums";
import { ProductType } from "@bitwarden/common/billing/enums";
export const freeTrialTextResolver: ResolveFn<string | null> = (
route: ActivatedRouteSnapshot,
): string | null => {
const { product, productTier } = route.queryParams;
const { product } = route.queryParams;
const products: ProductType[] = (product ?? "").split(",").map((p: string) => parseInt(p));
const onlyPasswordManager = products.length === 1 && products[0] === ProductType.PasswordManager;
const onlySecretsManager = products.length === 1 && products[0] === ProductType.SecretsManager;
const forTeams = parseInt(productTier) === ProductTierType.Teams;
const forEnterprise = parseInt(productTier) === ProductTierType.Enterprise;
const forFamilies = parseInt(productTier) === ProductTierType.Families;
switch (true) {
case onlyPasswordManager && forTeams:
return "startYour7DayFreeTrialOfBitwardenPasswordManagerForTeams";
case onlyPasswordManager && forEnterprise:
return "startYour7DayFreeTrialOfBitwardenPasswordManagerForEnterprise";
case onlyPasswordManager && forFamilies:
return "startYour7DayFreeTrialOfBitwardenPasswordManagerForFamilies";
case onlyPasswordManager:
return "startYour7DayFreeTrialOfBitwardenPasswordManager";
case onlySecretsManager && forTeams:
return "startYour7DayFreeTrialOfBitwardenSecretsManagerForTeams";
case onlySecretsManager && forEnterprise:
return "startYour7DayFreeTrialOfBitwardenSecretsManagerForEnterprise";
case onlySecretsManager && forFamilies:
return "startYour7DayFreeTrialOfBitwardenSecretsManagerForFamilies";
return "continueSettingUpFreeTrialPasswordManager";
case onlySecretsManager:
return "startYour7DayFreeTrialOfBitwardenSecretsManager";
case forTeams:
return "startYour7DayFreeTrialOfBitwardenForTeams";
case forEnterprise:
return "startYour7DayFreeTrialOfBitwardenForEnterprise";
case forFamilies:
return "startYour7DayFreeTrialOfBitwardenForFamilies";
return "continueSettingUpFreeTrialSecretsManager";
default:
return "startYour7DayFreeTrialOfBitwarden";
return "continueSettingUpFreeTrial";
}
};

View File

@ -11,7 +11,7 @@
<form [formGroup]="selfHostedForm" [bitSubmit]="submit">
<bit-form-field>
<bit-label>{{ "licenseFile" | i18n }}</bit-label>
<div>
<div class="tw-pt-2 tw-pb-1">
<button bitButton type="button" buttonType="secondary" (click)="fileSelector.click()">
{{ "chooseFile" | i18n }}
</button>

View File

@ -8683,41 +8683,14 @@
"manageBillingFromProviderPortalMessage": {
"message": "Manage billing from the Provider Portal"
},
"startYour7DayFreeTrialOfBitwarden": {
"message": "Start your 7-Day free trial of Bitwarden"
"continueSettingUpFreeTrial": {
"message": "Continue setting up your free trial of Bitwarden"
},
"startYour7DayFreeTrialOfBitwardenForTeams": {
"message": "Start your 7-Day free trial of Bitwarden for Teams"
"continueSettingUpFreeTrialPasswordManager": {
"message": "Continue setting up your free trial of Bitwarden Password Manager"
},
"startYour7DayFreeTrialOfBitwardenForFamilies": {
"message": "Start your 7-Day free trial of Bitwarden for Families"
},
"startYour7DayFreeTrialOfBitwardenForEnterprise": {
"message": "Start your 7-Day free trial of Bitwarden for Enterprise"
},
"startYour7DayFreeTrialOfBitwardenSecretsManager": {
"message": "Start your 7-Day free trial of Bitwarden Secrets Manager"
},
"startYour7DayFreeTrialOfBitwardenSecretsManagerForTeams": {
"message": "Start your 7-Day free trial of Bitwarden Secrets Manager for Teams"
},
"startYour7DayFreeTrialOfBitwardenSecretsManagerForFamilies": {
"message": "Start your 7-Day free trial of Bitwarden Secrets Manager for Families"
},
"startYour7DayFreeTrialOfBitwardenSecretsManagerForEnterprise": {
"message": "Start your 7-Day free trial of Bitwarden Secrets Manager for Enterprise"
},
"startYour7DayFreeTrialOfBitwardenPasswordManager": {
"message": "Start your 7-Day free trial of Bitwarden Password Manager"
},
"startYour7DayFreeTrialOfBitwardenPasswordManagerForTeams": {
"message": "Start your 7-Day free trial of Bitwarden Password Manager for Teams"
},
"startYour7DayFreeTrialOfBitwardenPasswordManagerForFamilies": {
"message": "Start your 7-Day free trial of Bitwarden Password Manager for Families"
},
"startYour7DayFreeTrialOfBitwardenPasswordManagerForEnterprise": {
"message": "Start your 7-Day free trial of Bitwarden Password Manager for Enterprise"
"continueSettingUpFreeTrialSecretsManager": {
"message": "Continue setting up your free trial of Bitwarden Secrets Manager"
},
"enterTeamsOrgInfo": {
"message": "Enter your Teams organization information"
@ -9029,6 +9002,24 @@
"purchasedSeatsRemoved": {
"message": "purchased seats removed"
},
"environmentVariables": {
"message": "Environment variables"
},
"organizationId": {
"message": "Organization ID"
},
"projectIds": {
"message": "Project IDs"
},
"projectId": {
"message": "Project ID"
},
"projectsAccessedByMachineAccount": {
"message": "The following projects can be accessed by this machine account."
},
"config": {
"message": "Config"
},
"learnMoreAboutEmergencyAccess": {
"message":"Learn more about emergency access"
},

View File

@ -6,4 +6,5 @@ export class ProjectListView {
revisionDate: string;
read: boolean;
write: boolean;
linkable: boolean;
}

View File

@ -131,6 +131,7 @@ export class ProjectService {
);
projectListView.creationDate = s.creationDate;
projectListView.revisionDate = s.revisionDate;
projectListView.linkable = true;
return projectListView;
}),
);

View File

@ -0,0 +1,47 @@
<div *ngIf="!loading">
<div class="tw-p-6 tw-border tw-border-solid tw-border-secondary-600 tw-rounded">
<h2 bitTypography="h2">{{ "environmentVariables" | i18n }}</h2>
<div class="tw-flex tw-gap-6 tw-pt-4">
<bit-form-field class="tw-w-2/5 tw-min-w-80" tw>
<bit-label>{{ "identityUrl" | i18n }}</bit-label>
<input bitInput type="text" [(ngModel)]="identityUrl" [disabled]="true" />
<button
bitSuffix
type="button"
type="button"
bitIconButton="bwi-clone"
[bitAction]="copyIdentityUrl"
></button>
</bit-form-field>
<bit-form-field class="tw-w-2/5 tw-min-w-80">
<bit-label>{{ "apiUrl" | i18n }}</bit-label>
<input bitInput type="text" [(ngModel)]="apiUrl" [disabled]="true" />
<button bitSuffix type="button" bitIconButton="bwi-clone" [bitAction]="copyApiUrl"></button>
</bit-form-field>
</div>
<bit-form-field class="tw-w-2/5 tw-min-w-80">
<bit-label>{{ "organizationId" | i18n }}</bit-label>
<input bitInput type="text" [(ngModel)]="organizationId" [disabled]="true" />
<button
bitSuffix
type="button"
bitIconButton="bwi-clone"
[bitAction]="copyOrganizationId"
></button>
</bit-form-field>
</div>
<div class="tw-pt-12">
<h2 slot="summary" class="tw-mb-0" bitTypography="h2" noMargin>{{ "projectIds" | i18n }}</h2>
<p *ngIf="!hasProjects" class="tw-mt-6">{{ "projectsNoItemsTitle" | i18n }}</p>
<p *ngIf="hasProjects" class="tw-mt-4">{{ "projectsAccessedByMachineAccount" | i18n }}</p>
<sm-projects-list
class="tw-mt-8"
*ngIf="hasProjects"
[showMenus]="false"
[projects]="projects"
></sm-projects-list>
</div>
</div>
<div *ngIf="loading" class="tw-items-center tw-justify-center tw-pt-64 tw-text-center">
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
</div>

View File

@ -0,0 +1,127 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Params } from "@angular/router";
import { Subject, concatMap, takeUntil } from "rxjs";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
import { ProjectListView } from "../../models/view/project-list.view";
import { ProjectService } from "../../projects/project.service";
import { AccessPolicyService } from "../../shared/access-policies/access-policy.service";
class ServiceAccountConfig {
organizationId: string;
serviceAccountId: string;
identityUrl: string;
apiUrl: string;
projects: ProjectListView[];
}
@Component({
selector: "sm-service-account-config",
templateUrl: "./config.component.html",
})
export class ServiceAccountConfigComponent implements OnInit, OnDestroy {
identityUrl: string;
apiUrl: string;
organizationId: string;
serviceAccountId: string;
projects: ProjectListView[];
hasProjects = false;
private destroy$ = new Subject<void>();
loading = true;
constructor(
private environmentService: EnvironmentService,
private route: ActivatedRoute,
private platformUtilsService: PlatformUtilsService,
private toastService: ToastService,
private i18nService: I18nService,
private projectService: ProjectService,
private accessPolicyService: AccessPolicyService,
) {}
async ngOnInit() {
this.route.params
.pipe(
concatMap(async (params: Params) => {
return await this.load(params.organizationId, params.serviceAccountId);
}),
takeUntil(this.destroy$),
)
.subscribe((smConfig) => {
this.identityUrl = smConfig.identityUrl;
this.apiUrl = smConfig.apiUrl;
this.organizationId = smConfig.organizationId;
this.serviceAccountId = smConfig.serviceAccountId;
this.projects = smConfig.projects;
this.hasProjects = smConfig.projects.length > 0;
this.loading = false;
});
}
async load(organizationId: string, serviceAccountId: string): Promise<ServiceAccountConfig> {
const environment = await this.environmentService.getEnvironment();
const allProjects = await this.projectService.getProjects(organizationId);
const policies = await this.accessPolicyService.getServiceAccountGrantedPolicies(
organizationId,
serviceAccountId,
);
const projects = policies.grantedProjectPolicies.map((policy) => {
return {
id: policy.accessPolicy.grantedProjectId,
name: policy.accessPolicy.grantedProjectName,
organizationId: organizationId,
linkable: allProjects.some(
(project) => project.id === policy.accessPolicy.grantedProjectId,
),
} as ProjectListView;
});
return {
organizationId: organizationId,
serviceAccountId: serviceAccountId,
identityUrl: environment.getIdentityUrl(),
apiUrl: environment.getApiUrl(),
projects: projects,
} as ServiceAccountConfig;
}
copyIdentityUrl = () => {
this.platformUtilsService.copyToClipboard(this.identityUrl);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("valueCopied", this.i18nService.t("identityUrl")),
});
};
copyApiUrl = () => {
this.platformUtilsService.copyToClipboard(this.apiUrl);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("valueCopied", this.i18nService.t("apiUrl")),
});
};
copyOrganizationId = () => {
this.platformUtilsService.copyToClipboard(this.organizationId);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("valueCopied", this.i18nService.t("organizationId")),
});
};
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -29,6 +29,7 @@
</div>
</bit-tab-link>
<bit-tab-link [route]="['events']">{{ "eventLogs" | i18n }}</bit-tab-link>
<bit-tab-link [route]="['config']">{{ "config" | i18n }}</bit-tab-link>
</bit-tab-nav-bar>
<button
type="button"

View File

@ -2,6 +2,7 @@ import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { AccessTokenComponent } from "./access/access-tokens.component";
import { ServiceAccountConfigComponent } from "./config/config.component";
import { ServiceAccountEventsComponent } from "./event-logs/service-accounts-events.component";
import { serviceAccountAccessGuard } from "./guards/service-account-access.guard";
import { ServiceAccountPeopleComponent } from "./people/service-account-people.component";
@ -40,6 +41,10 @@ const routes: Routes = [
path: "events",
component: ServiceAccountEventsComponent,
},
{
path: "config",
component: ServiceAccountConfigComponent,
},
],
},
];

View File

@ -9,6 +9,7 @@ import { AccessTokenComponent } from "./access/access-tokens.component";
import { AccessTokenCreateDialogComponent } from "./access/dialogs/access-token-create-dialog.component";
import { AccessTokenDialogComponent } from "./access/dialogs/access-token-dialog.component";
import { ExpirationOptionsComponent } from "./access/dialogs/expiration-options.component";
import { ServiceAccountConfigComponent } from "./config/config.component";
import { ServiceAccountDeleteDialogComponent } from "./dialog/service-account-delete-dialog.component";
import { ServiceAccountDialogComponent } from "./dialog/service-account-dialog.component";
import { ServiceAccountEventsComponent } from "./event-logs/service-accounts-events.component";
@ -28,6 +29,7 @@ import { ServiceAccountsComponent } from "./service-accounts.component";
AccessTokenDialogComponent,
ExpirationOptionsComponent,
ServiceAccountComponent,
ServiceAccountConfigComponent,
ServiceAccountDeleteDialogComponent,
ServiceAccountDialogComponent,
ServiceAccountEventsComponent,

View File

@ -20,7 +20,7 @@
<bit-table *ngIf="projects?.length >= 1" [dataSource]="dataSource">
<ng-container header>
<tr>
<th bitCell class="tw-w-0">
<th bitCell class="tw-w-0" *ngIf="showMenus">
<label class="!tw-mb-0 tw-flex tw-w-fit tw-gap-2 !tw-font-bold !tw-text-muted">
<input
type="checkbox"
@ -32,7 +32,7 @@
</label>
</th>
<th bitCell bitSortable="name" default>{{ "name" | i18n }}</th>
<th bitCell bitSortable="revisionDate">{{ "lastEdited" | i18n }}</th>
<th bitCell bitSortable="revisionDate" *ngIf="showMenus">{{ "lastEdited" | i18n }}</th>
<th
bitCell
class="tw-w-0"
@ -45,13 +45,14 @@
[bitMenuTriggerFor]="tableMenu"
[title]="'options' | i18n"
[attr.aria-label]="'options' | i18n"
*ngIf="showMenus"
></button>
</th>
</tr>
</ng-container>
<ng-template body let-rows$>
<tr bitRow *ngFor="let project of rows$ | async">
<td bitCell>
<td bitCell *ngIf="showMenus">
<input
type="checkbox"
(change)="$event ? selection.toggle(project.id) : null"
@ -61,12 +62,32 @@
<td bitCell>
<div class="tw-flex tw-items-center tw-gap-4 tw-break-all">
<i class="bwi bwi-collection tw-text-muted" aria-hidden="true"></i>
<a bitLink [routerLink]="['/sm', project.organizationId, 'projects', project.id]">{{
project.name
}}</a>
<div>
<a
*ngIf="project.linkable"
bitLink
[routerLink]="['/sm', project.organizationId, 'projects', project.id]"
>{{ project.name }}</a
>
<span *ngIf="!project.linkable">{{ project.name }}</span>
<div class="tw-text-sm tw-text-muted tw-block">
{{ project.id }}
<button
type="button"
bitIconButton="bwi-clone"
buttonType="main"
size="small"
[title]="'copyUuid' | i18n"
[attr.aria-label]="'copyUuid' | i18n"
(click)="copyProjectUuidToClipboard(project.id)"
></button>
</div>
</div>
</div>
</td>
<td bitCell class="tw-whitespace-nowrap">{{ project.revisionDate | date: "medium" }}</td>
<td bitCell class="tw-whitespace-nowrap" *ngIf="showMenus">
{{ project.revisionDate | date: "medium" }}
</td>
<td bitCell>
<button
type="button"
@ -75,6 +96,7 @@
[bitMenuTriggerFor]="projectMenu"
[title]="'options' | i18n"
[attr.aria-label]="'options' | i18n"
*ngIf="showMenus"
></button>
</td>
<bit-menu #projectMenu>

View File

@ -24,6 +24,8 @@ export class ProjectsListComponent {
}
private _projects: ProjectListView[];
@Input() showMenus?: boolean = true;
@Input()
set search(search: string) {
this.selection.clear();
@ -33,6 +35,7 @@ export class ProjectsListComponent {
@Output() editProjectEvent = new EventEmitter<string>();
@Output() deleteProjectEvent = new EventEmitter<ProjectListView[]>();
@Output() newProjectEvent = new EventEmitter();
@Output() copiedProjectUUIdEvent = new EventEmitter<string>();
selection = new SelectionModel<string>(true, []);
protected dataSource = new TableDataSource<ProjectListView>();
@ -90,4 +93,13 @@ export class ProjectsListComponent {
}
return false;
}
copyProjectUuidToClipboard(id: string) {
this.platformUtilsService.copyToClipboard(id);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("valueCopied", this.i18nService.t("projectId")),
);
}
}

View File

@ -1,10 +1,12 @@
import { NgModule } from "@angular/core";
import {
CardComponent,
MultiSelectModule,
SearchModule,
SelectModule,
NoItemsModule,
FormFieldModule,
} from "@bitwarden/components";
import { CoreOrganizationModule } from "@bitwarden/web-vault/app/admin-console/organizations/core";
import { DynamicAvatarComponent } from "@bitwarden/web-vault/app/components/dynamic-avatar.component";
@ -31,17 +33,21 @@ import { SecretsListComponent } from "./secrets-list.component";
DynamicAvatarComponent,
SearchModule,
HeaderModule,
CardComponent,
FormFieldModule,
],
exports: [
AccessPolicySelectorComponent,
BulkConfirmationDialogComponent,
BulkStatusDialogComponent,
FormFieldModule,
HeaderModule,
NewMenuComponent,
NoItemsModule,
ProjectsListComponent,
SearchModule,
SecretsListComponent,
CardComponent,
SelectModule,
SharedModule,
],

View File

@ -34,7 +34,6 @@ export enum FeatureFlag {
AccountDeprovisioning = "pm-10308-account-deprovisioning",
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
AC2476_DeprecateStripeSourcesAPI = "AC-2476-deprecate-stripe-sources-api",
StorageReseedRefactor = "storage-reseed-refactor",
CipherKeyEncryption = "cipher-key-encryption",
}
@ -77,7 +76,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE,
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
[FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE,
[FeatureFlag.StorageReseedRefactor]: FALSE,
[FeatureFlag.AccountDeprovisioning]: FALSE,
[FeatureFlag.NotificationBarAddLoginImprovements]: FALSE,
[FeatureFlag.AC2476_DeprecateStripeSourcesAPI]: FALSE,