[PM-13452] - add password health raw data component (#11519)
* add raw data component * fix tests * simplify logic. fix tests * revert change to default config service * remove cipher report dep. fix tests. * revert changes to mock data and specs * remove mock data * use orgId param * fix test
This commit is contained in:
parent
1f330b078d
commit
d70d2cb995
|
@ -1,7 +1,6 @@
|
||||||
import { NgModule } from "@angular/core";
|
import { NgModule } from "@angular/core";
|
||||||
import { RouterModule, Routes } from "@angular/router";
|
import { RouterModule, Routes } from "@angular/router";
|
||||||
|
|
||||||
import { unauthGuardFn } from "@bitwarden/angular/auth/guards";
|
|
||||||
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
|
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
|
||||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
|
|
||||||
|
@ -11,7 +10,7 @@ const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: "",
|
path: "",
|
||||||
component: AccessIntelligenceComponent,
|
component: AccessIntelligenceComponent,
|
||||||
canActivate: [canAccessFeature(FeatureFlag.AccessIntelligence), unauthGuardFn()],
|
canActivate: [canAccessFeature(FeatureFlag.AccessIntelligence)],
|
||||||
data: {
|
data: {
|
||||||
titleId: "accessIntelligence",
|
titleId: "accessIntelligence",
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
<app-header></app-header>
|
<app-header></app-header>
|
||||||
<bit-tab-group [(selectedIndex)]="tabIndex">
|
<bit-tab-group [(selectedIndex)]="tabIndex">
|
||||||
<bit-tab label="{{ 'allApplicationsWithCount' | i18n: apps.length }}">
|
<bit-tab label="Raw Data">
|
||||||
|
<tools-password-health></tools-password-health>
|
||||||
|
</bit-tab>
|
||||||
|
<!-- <bit-tab label="{{ 'allApplicationsWithCount' | i18n: apps.length }}">
|
||||||
<h2 bitTypography="h2">{{ "allApplications" | i18n }}</h2>
|
<h2 bitTypography="h2">{{ "allApplications" | i18n }}</h2>
|
||||||
<tools-application-table></tools-application-table>
|
<tools-application-table></tools-application-table>
|
||||||
</bit-tab>
|
</bit-tab>
|
||||||
|
@ -19,5 +22,5 @@
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<h2 bitTypography="h2">{{ "notifiedMembers" | i18n }}</h2>
|
<h2 bitTypography="h2">{{ "notifiedMembers" | i18n }}</h2>
|
||||||
<tools-notified-members-table></tools-notified-members-table>
|
<tools-notified-members-table></tools-notified-members-table>
|
||||||
</bit-tab>
|
</bit-tab> -->
|
||||||
</bit-tab-group>
|
</bit-tab-group>
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { HeaderModule } from "../../layouts/header/header.module";
|
||||||
|
|
||||||
import { ApplicationTableComponent } from "./application-table.component";
|
import { ApplicationTableComponent } from "./application-table.component";
|
||||||
import { NotifiedMembersTableComponent } from "./notified-members-table.component";
|
import { NotifiedMembersTableComponent } from "./notified-members-table.component";
|
||||||
|
import { PasswordHealthComponent } from "./password-health.component";
|
||||||
|
|
||||||
export enum AccessIntelligenceTabType {
|
export enum AccessIntelligenceTabType {
|
||||||
AllApps = 0,
|
AllApps = 0,
|
||||||
|
@ -26,6 +27,7 @@ export enum AccessIntelligenceTabType {
|
||||||
CommonModule,
|
CommonModule,
|
||||||
JslibModule,
|
JslibModule,
|
||||||
HeaderModule,
|
HeaderModule,
|
||||||
|
PasswordHealthComponent,
|
||||||
NotifiedMembersTableComponent,
|
NotifiedMembersTableComponent,
|
||||||
TabsModule,
|
TabsModule,
|
||||||
],
|
],
|
||||||
|
|
|
@ -0,0 +1,57 @@
|
||||||
|
<bit-container>
|
||||||
|
<p>{{ "passwordsReportDesc" | i18n }}</p>
|
||||||
|
<div *ngIf="loading">
|
||||||
|
<i
|
||||||
|
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||||
|
title="{{ 'loading' | i18n }}"
|
||||||
|
aria-hidden="true"
|
||||||
|
></i>
|
||||||
|
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tw-mt-4" *ngIf="!loading">
|
||||||
|
<bit-table [dataSource]="dataSource">
|
||||||
|
<ng-container header>
|
||||||
|
<tr bitRow>
|
||||||
|
<th bitCell></th>
|
||||||
|
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
|
||||||
|
<th bitCell class="tw-text-right">{{ "weakness" | i18n }}</th>
|
||||||
|
<th bitCell class="tw-text-right">{{ "timesReused" | i18n }}</th>
|
||||||
|
<th bitCell class="tw-text-right">{{ "timesExposed" | i18n }}</th>
|
||||||
|
</tr>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template body let-rows$>
|
||||||
|
<tr bitRow *ngFor="let r of rows$ | async">
|
||||||
|
<td bitCell>
|
||||||
|
<app-vault-icon [cipher]="r"></app-vault-icon>
|
||||||
|
</td>
|
||||||
|
<td bitCell>
|
||||||
|
<ng-container>
|
||||||
|
<span>{{ r.name }}</span>
|
||||||
|
</ng-container>
|
||||||
|
<br />
|
||||||
|
<small>{{ r.subTitle }}</small>
|
||||||
|
</td>
|
||||||
|
<td bitCell class="tw-text-right">
|
||||||
|
<span
|
||||||
|
bitBadge
|
||||||
|
*ngIf="passwordStrengthMap.has(r.id)"
|
||||||
|
[variant]="passwordStrengthMap.get(r.id)[1]"
|
||||||
|
>
|
||||||
|
{{ passwordStrengthMap.get(r.id)[0] | i18n }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td bitCell class="tw-text-right">
|
||||||
|
<span bitBadge *ngIf="passwordUseMap.has(r.login.password)" variant="warning">
|
||||||
|
{{ "reusedXTimes" | i18n: passwordUseMap.get(r.login.password) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td bitCell class="tw-text-right">
|
||||||
|
<span bitBadge *ngIf="exposedPasswordMap.has(r.id)" variant="warning">
|
||||||
|
{{ "exposedXTimes" | i18n: exposedPasswordMap.get(r.id) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
</bit-table>
|
||||||
|
</div>
|
||||||
|
</bit-container>
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||||
|
import { ActivatedRoute, convertToParamMap } from "@angular/router";
|
||||||
|
import { MockProxy, mock } from "jest-mock-extended";
|
||||||
|
import { of } from "rxjs";
|
||||||
|
|
||||||
|
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||||
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||||
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
|
import { TableModule } from "@bitwarden/components";
|
||||||
|
import { TableBodyDirective } from "@bitwarden/components/src/table/table.component";
|
||||||
|
|
||||||
|
import { LooseComponentsModule } from "../../shared";
|
||||||
|
import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module";
|
||||||
|
// eslint-disable-next-line no-restricted-imports
|
||||||
|
import { cipherData } from "../reports/pages/reports-ciphers.mock";
|
||||||
|
|
||||||
|
import { PasswordHealthComponent } from "./password-health.component";
|
||||||
|
|
||||||
|
describe("PasswordHealthComponent", () => {
|
||||||
|
let component: PasswordHealthComponent;
|
||||||
|
let fixture: ComponentFixture<PasswordHealthComponent>;
|
||||||
|
let passwordStrengthService: MockProxy<PasswordStrengthServiceAbstraction>;
|
||||||
|
let organizationService: MockProxy<OrganizationService>;
|
||||||
|
let cipherServiceMock: MockProxy<CipherService>;
|
||||||
|
let auditServiceMock: MockProxy<AuditService>;
|
||||||
|
const activeRouteParams = convertToParamMap({ organizationId: "orgId" });
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
passwordStrengthService = mock<PasswordStrengthServiceAbstraction>();
|
||||||
|
auditServiceMock = mock<AuditService>();
|
||||||
|
organizationService = mock<OrganizationService>({
|
||||||
|
get: jest.fn().mockResolvedValue({ id: "orgId" } as Organization),
|
||||||
|
});
|
||||||
|
cipherServiceMock = mock<CipherService>({
|
||||||
|
getAllFromApiForOrganization: jest.fn().mockResolvedValue(cipherData),
|
||||||
|
});
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [PasswordHealthComponent, PipesModule, TableModule, LooseComponentsModule],
|
||||||
|
declarations: [TableBodyDirective],
|
||||||
|
providers: [
|
||||||
|
{ provide: CipherService, useValue: cipherServiceMock },
|
||||||
|
{ provide: PasswordStrengthServiceAbstraction, useValue: passwordStrengthService },
|
||||||
|
{ provide: OrganizationService, useValue: organizationService },
|
||||||
|
{ provide: I18nService, useValue: mock<I18nService>() },
|
||||||
|
{ provide: AuditService, useValue: auditServiceMock },
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: {
|
||||||
|
paramMap: of(activeRouteParams),
|
||||||
|
url: of([]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(PasswordHealthComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should initialize component", () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should populate reportCiphers with ciphers that have password issues", async () => {
|
||||||
|
passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 1 } as any);
|
||||||
|
|
||||||
|
auditServiceMock.passwordLeaked.mockResolvedValue(5);
|
||||||
|
|
||||||
|
await component.setCiphers();
|
||||||
|
|
||||||
|
const cipherIds = component.reportCiphers.map((c) => c.id);
|
||||||
|
|
||||||
|
expect(cipherIds).toEqual([
|
||||||
|
"cbea34a8-bde4-46ad-9d19-b05001228ab1",
|
||||||
|
"cbea34a8-bde4-46ad-9d19-b05001228ab2",
|
||||||
|
"cbea34a8-bde4-46ad-9d19-b05001228cd3",
|
||||||
|
]);
|
||||||
|
expect(component.reportCiphers.length).toEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should correctly populate passwordStrengthMap", async () => {
|
||||||
|
passwordStrengthService.getPasswordStrength.mockImplementation((password) => {
|
||||||
|
let score = 0;
|
||||||
|
if (password === "123") {
|
||||||
|
score = 1;
|
||||||
|
} else {
|
||||||
|
score = 4;
|
||||||
|
}
|
||||||
|
return { score } as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
auditServiceMock.passwordLeaked.mockResolvedValue(0);
|
||||||
|
|
||||||
|
await component.setCiphers();
|
||||||
|
|
||||||
|
expect(component.passwordStrengthMap.size).toBeGreaterThan(0);
|
||||||
|
expect(component.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab2")).toEqual([
|
||||||
|
"veryWeak",
|
||||||
|
"danger",
|
||||||
|
]);
|
||||||
|
expect(component.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228cd3")).toEqual([
|
||||||
|
"veryWeak",
|
||||||
|
"danger",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,229 @@
|
||||||
|
import { CommonModule } from "@angular/common";
|
||||||
|
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
|
||||||
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
|
import { ActivatedRoute } from "@angular/router";
|
||||||
|
import { from, map, switchMap, tap } from "rxjs";
|
||||||
|
|
||||||
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
|
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||||
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
|
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||||
|
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 {
|
||||||
|
BadgeModule,
|
||||||
|
BadgeVariant,
|
||||||
|
ContainerComponent,
|
||||||
|
TableDataSource,
|
||||||
|
TableModule,
|
||||||
|
} from "@bitwarden/components";
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-imports
|
||||||
|
import { HeaderModule } from "../../layouts/header/header.module";
|
||||||
|
// eslint-disable-next-line no-restricted-imports
|
||||||
|
import { OrganizationBadgeModule } from "../../vault/individual-vault/organization-badge/organization-badge.module";
|
||||||
|
// eslint-disable-next-line no-restricted-imports
|
||||||
|
import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
selector: "tools-password-health",
|
||||||
|
templateUrl: "password-health.component.html",
|
||||||
|
imports: [
|
||||||
|
BadgeModule,
|
||||||
|
OrganizationBadgeModule,
|
||||||
|
CommonModule,
|
||||||
|
ContainerComponent,
|
||||||
|
PipesModule,
|
||||||
|
JslibModule,
|
||||||
|
HeaderModule,
|
||||||
|
TableModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class PasswordHealthComponent implements OnInit {
|
||||||
|
passwordStrengthMap = new Map<string, [string, BadgeVariant]>();
|
||||||
|
|
||||||
|
weakPasswordCiphers: CipherView[] = [];
|
||||||
|
|
||||||
|
passwordUseMap = new Map<string, number>();
|
||||||
|
|
||||||
|
exposedPasswordMap = new Map<string, number>();
|
||||||
|
|
||||||
|
dataSource = new TableDataSource<CipherView>();
|
||||||
|
|
||||||
|
reportCiphers: CipherView[] = [];
|
||||||
|
reportCipherIds: string[] = [];
|
||||||
|
|
||||||
|
organization: Organization;
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected cipherService: CipherService,
|
||||||
|
protected passwordStrengthService: PasswordStrengthServiceAbstraction,
|
||||||
|
protected organizationService: OrganizationService,
|
||||||
|
protected auditService: AuditService,
|
||||||
|
protected i18nService: I18nService,
|
||||||
|
protected activatedRoute: ActivatedRoute,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.activatedRoute.paramMap
|
||||||
|
.pipe(
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
map((params) => params.get("organizationId")),
|
||||||
|
switchMap((organizationId) => {
|
||||||
|
return from(this.organizationService.get(organizationId));
|
||||||
|
}),
|
||||||
|
tap((organization) => {
|
||||||
|
this.organization = organization;
|
||||||
|
}),
|
||||||
|
switchMap(() => from(this.setCiphers())),
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
async setCiphers() {
|
||||||
|
const allCiphers = await this.cipherService.getAllFromApiForOrganization(this.organization.id);
|
||||||
|
allCiphers.forEach(async (cipher) => {
|
||||||
|
this.findWeakPassword(cipher);
|
||||||
|
this.findReusedPassword(cipher);
|
||||||
|
await this.findExposedPassword(cipher);
|
||||||
|
});
|
||||||
|
this.dataSource.data = this.reportCiphers;
|
||||||
|
this.loading = false;
|
||||||
|
|
||||||
|
// const reportIssues = allCiphers.map((c) => {
|
||||||
|
// if (this.passwordStrengthMap.has(c.id)) {
|
||||||
|
// return c;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (this.passwordUseMap.has(c.id)) {
|
||||||
|
// return c;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (this.exposedPasswordMap.has(c.id)) {
|
||||||
|
// return c;
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected checkForExistingCipher(ciph: CipherView) {
|
||||||
|
if (!this.reportCipherIds.includes(ciph.id)) {
|
||||||
|
this.reportCipherIds.push(ciph.id);
|
||||||
|
this.reportCiphers.push(ciph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async findExposedPassword(cipher: CipherView) {
|
||||||
|
const { type, login, isDeleted, edit, viewPassword, id } = cipher;
|
||||||
|
if (
|
||||||
|
type !== CipherType.Login ||
|
||||||
|
login.password == null ||
|
||||||
|
login.password === "" ||
|
||||||
|
isDeleted ||
|
||||||
|
(!this.organization && !edit) ||
|
||||||
|
!viewPassword
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exposedCount = await this.auditService.passwordLeaked(login.password);
|
||||||
|
if (exposedCount > 0) {
|
||||||
|
this.exposedPasswordMap.set(id, exposedCount);
|
||||||
|
this.checkForExistingCipher(cipher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected findReusedPassword(cipher: CipherView) {
|
||||||
|
const { type, login, isDeleted, edit, viewPassword } = cipher;
|
||||||
|
if (
|
||||||
|
type !== CipherType.Login ||
|
||||||
|
login.password == null ||
|
||||||
|
login.password === "" ||
|
||||||
|
isDeleted ||
|
||||||
|
(!this.organization && !edit) ||
|
||||||
|
!viewPassword
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.passwordUseMap.has(login.password)) {
|
||||||
|
this.passwordUseMap.set(login.password, this.passwordUseMap.get(login.password) || 0 + 1);
|
||||||
|
} else {
|
||||||
|
this.passwordUseMap.set(login.password, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.checkForExistingCipher(cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected findWeakPassword(cipher: CipherView): void {
|
||||||
|
const { type, login, isDeleted, edit, viewPassword } = cipher;
|
||||||
|
if (
|
||||||
|
type !== CipherType.Login ||
|
||||||
|
login.password == null ||
|
||||||
|
login.password === "" ||
|
||||||
|
isDeleted ||
|
||||||
|
(!this.organization && !edit) ||
|
||||||
|
!viewPassword
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasUserName = this.isUserNameNotEmpty(cipher);
|
||||||
|
let userInput: string[] = [];
|
||||||
|
if (hasUserName) {
|
||||||
|
const atPosition = login.username.indexOf("@");
|
||||||
|
if (atPosition > -1) {
|
||||||
|
userInput = userInput
|
||||||
|
.concat(
|
||||||
|
login.username
|
||||||
|
.substring(0, atPosition)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^A-Za-z0-9]/),
|
||||||
|
)
|
||||||
|
.filter((i) => i.length >= 3);
|
||||||
|
} else {
|
||||||
|
userInput = login.username
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^A-Za-z0-9]/)
|
||||||
|
.filter((i) => i.length >= 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { score } = this.passwordStrengthService.getPasswordStrength(
|
||||||
|
login.password,
|
||||||
|
null,
|
||||||
|
userInput.length > 0 ? userInput : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (score != null && score <= 2) {
|
||||||
|
this.passwordStrengthMap.set(cipher.id, this.scoreKey(score));
|
||||||
|
this.checkForExistingCipher(cipher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isUserNameNotEmpty(c: CipherView): boolean {
|
||||||
|
return !Utils.isNullOrWhitespace(c.login.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scoreKey(score: number): [string, BadgeVariant] {
|
||||||
|
switch (score) {
|
||||||
|
case 4:
|
||||||
|
return ["strong", "success"];
|
||||||
|
case 3:
|
||||||
|
return ["good", "primary"];
|
||||||
|
case 2:
|
||||||
|
return ["weak", "warning"];
|
||||||
|
default:
|
||||||
|
return ["veryWeak", "danger"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue