Add support for Emergency Access (#707)

* Add support for Emergency Access

* Cleanup & Bugfix

* Apply suggestions from code review

Co-authored-by: Addison Beck <addisonbeck1@gmail.com>

* Cleanup some more imports

* Restrict emergency access invite to premium users

* Restrict editing existing emergency accesses to premium account.

* Handle changes in jslib

* Add some info messages for when you haven't been granted or invited emergency contacts

* Resolve review comments

* Update jslib

Co-authored-by: Addison Beck <addisonbeck1@gmail.com>
This commit is contained in:
Oscar Hinton 2020-12-22 16:57:44 +01:00 committed by GitHub
parent 54b68ac543
commit 3c5a972bc9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1409 additions and 53 deletions

View File

@ -13,3 +13,6 @@ insert_final_newline = true
charset = utf-8
indent_style = space
indent_size = 4
[*.{ts}]
quote_type = single

2
jslib

@ -1 +1 @@
Subproject commit 697e755c0f43119e0811e2c9452c0d9d925970eb
Subproject commit 573eea66eeff1a763e7ed0477fbba51e02c2c5b1

View File

@ -0,0 +1,35 @@
<div class="mt-5 d-flex justify-content-center" *ngIf="loading">
<div>
<img src="../../images/logo-dark@2x.png" class="mb-4 logo" alt="Bitwarden">
<p class="text-center">
<i class="fa fa-spinner fa-spin fa-2x text-muted" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span class="sr-only">{{'loading' | i18n}}</span>
</p>
</div>
</div>
<div class="container" *ngIf="!loading && !authed">
<div class="row justify-content-md-center mt-5">
<div class="col-5">
<p class="lead text-center mb-4">{{'emergencyAccess' | i18n}}</p>
<div class="card d-block">
<div class="card-body">
<p class="text-center">
{{name}}
<strong class="d-block mt-2">{{email}}</strong>
</p>
<p>{{'acceptEmergencyAccess' | i18n}}</p>
<hr>
<div class="d-flex">
<a routerLink="/" [queryParams]="{email: email}" class="btn btn-primary btn-block">
{{'logIn' | i18n}}
</a>
<a routerLink="/register" [queryParams]="{email: email}"
class="btn btn-primary btn-block ml-2 mt-0">
{{'createAccount' | i18n}}
</a>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,93 @@
import {
Component,
OnInit,
} from '@angular/core';
import {
ActivatedRoute,
Router,
} from '@angular/router';
import {
Toast,
ToasterService,
} from 'angular2-toaster';
import { ApiService } from 'jslib/abstractions/api.service';
import { I18nService } from 'jslib/abstractions/i18n.service';
import { StateService } from 'jslib/abstractions/state.service';
import { UserService } from 'jslib/abstractions/user.service';
import { EmergencyAccessAcceptRequest } from 'jslib/models/request/emergencyAccessAcceptRequest';
@Component({
selector: 'app-accept-emergency',
templateUrl: 'accept-emergency.component.html',
})
export class AcceptEmergencyComponent implements OnInit {
loading = true;
authed = false;
name: string;
email: string;
actionPromise: Promise<any>;
constructor(private router: Router, private toasterService: ToasterService,
private i18nService: I18nService, private route: ActivatedRoute,
private apiService: ApiService, private userService: UserService,
private stateService: StateService) { }
ngOnInit() {
let fired = false;
this.route.queryParams.subscribe(async (qParams) => {
if (fired) {
return;
}
fired = true;
await this.stateService.remove('emergencyInvitation');
let error = qParams.id == null || qParams.name == null || qParams.email == null || qParams.token == null;
let errorMessage: string = null;
if (!error) {
this.authed = await this.userService.isAuthenticated();
if (this.authed) {
const request = new EmergencyAccessAcceptRequest();
request.token = qParams.token;
try {
this.actionPromise = this.apiService.postEmergencyAccessAccept(qParams.id, request);
await this.actionPromise;
const toast: Toast = {
type: 'success',
title: this.i18nService.t('inviteAccepted'),
body: this.i18nService.t('emergencyInviteAcceptedDesc'),
timeout: 10000,
};
this.toasterService.popAsync(toast);
this.router.navigate(['/vault']);
} catch (e) {
error = true;
errorMessage = e.message;
}
} else {
await this.stateService.save('emergencyInvitation', qParams);
this.email = qParams.email;
this.name = qParams.name;
if (this.name != null) {
// Fix URL encoding of space issue with Angular
this.name = this.name.replace(/\+/g, ' ');
}
}
}
if (error) {
const toast: Toast = {
type: 'error',
title: null,
body: errorMessage != null ? this.i18nService.t('emergencyInviteAcceptFailedShort', errorMessage) :
this.i18nService.t('emergencyInviteAcceptFailed'),
timeout: 10000,
};
this.toasterService.popAsync(toast);
this.router.navigate(['/']);
}
this.loading = false;
});
}
}

View File

@ -52,9 +52,12 @@ export class LoginComponent extends BaseLoginComponent {
}
async goAfterLogIn() {
const invite = await this.stateService.get<any>('orgInvitation');
if (invite != null) {
this.router.navigate(['accept-organization'], { queryParams: invite });
const orgInvite = await this.stateService.get<any>('orgInvitation');
const emergencyInvite = await this.stateService.get<any>('emergencyInvitation');
if (orgInvite != null) {
this.router.navigate(['accept-organization'], { queryParams: orgInvite });
} else if (emergencyInvite != null) {
this.router.navigate(['accept-emergency'], { queryParams: emergencyInvite });
} else {
const loginRedirect = await this.stateService.get<any>('loginRedirect');
if (loginRedirect != null) {

View File

@ -8,6 +8,7 @@ import { FrontendLayoutComponent } from './layouts/frontend-layout.component';
import { OrganizationLayoutComponent } from './layouts/organization-layout.component';
import { UserLayoutComponent } from './layouts/user-layout.component';
import { AcceptEmergencyComponent } from './accounts/accept-emergency.component';
import { AcceptOrganizationComponent } from './accounts/accept-organization.component';
import { HintComponent } from './accounts/hint.component';
import { LockComponent } from './accounts/lock.component';
@ -91,6 +92,8 @@ import { UnauthGuardService } from './services/unauth-guard.service';
import { AuthGuardService } from 'jslib/angular/services/auth-guard.service';
import { OrganizationUserType } from 'jslib/enums/organizationUserType';
import { EmergencyAccessComponent } from './settings/emergency-access.component';
import { EmergencyAccessViewComponent } from './settings/emergency-access-view.component';
const routes: Routes = [
{
@ -125,6 +128,11 @@ const routes: Routes = [
component: AcceptOrganizationComponent,
data: { titleId: 'joinOrganization' },
},
{
path: 'accept-emergency',
component: AcceptEmergencyComponent,
data: { titleId: 'acceptEmergency' },
},
{ path: 'recover', pathMatch: 'full', redirectTo: 'recover-2fa' },
{
path: 'recover-2fa',
@ -180,6 +188,21 @@ const routes: Routes = [
component: CreateOrganizationComponent,
data: { titleId: 'newOrganization' },
},
{
path: 'emergency-access',
children: [
{
path: '',
component: EmergencyAccessComponent,
data: { titleId: 'emergencyAccess'},
},
{
path: ':id',
component: EmergencyAccessViewComponent,
data: { titleId: 'emergencyAccess'},
},
],
},
],
},
{

View File

@ -27,6 +27,7 @@ import { NavbarComponent } from './layouts/navbar.component';
import { OrganizationLayoutComponent } from './layouts/organization-layout.component';
import { UserLayoutComponent } from './layouts/user-layout.component';
import { AcceptEmergencyComponent } from './accounts/accept-emergency.component';
import { AcceptOrganizationComponent } from './accounts/accept-organization.component';
import { HintComponent } from './accounts/hint.component';
import { LockComponent } from './accounts/lock.component';
@ -112,6 +113,12 @@ import { CreateOrganizationComponent } from './settings/create-organization.comp
import { DeauthorizeSessionsComponent } from './settings/deauthorize-sessions.component';
import { DeleteAccountComponent } from './settings/delete-account.component';
import { DomainRulesComponent } from './settings/domain-rules.component';
import { EmergencyAccessAddEditComponent } from './settings/emergency-access-add-edit.component';
import { EmergencyAccessComponent } from './settings/emergency-access.component';
import { EmergencyAccessConfirmComponent } from './settings/emergency-access-confirm.component';
import { EmergencyAccessTakeoverComponent } from './settings/emergency-access-takeover.component';
import { EmergencyAccessViewComponent } from './settings/emergency-access-view.component';
import { EmergencyAddEditComponent } from './settings/emergency-add-edit.component';
import { LinkSsoComponent } from './settings/link-sso.component';
import { OptionsComponent } from './settings/options.component';
import { OrganizationPlansComponent } from './settings/organization-plans.component';
@ -258,6 +265,7 @@ registerLocaleData(localeZhTw, 'zh-TW');
],
declarations: [
A11yTitleDirective,
AcceptEmergencyComponent,
AccessComponent,
AcceptOrganizationComponent,
AccountComponent,
@ -295,6 +303,12 @@ registerLocaleData(localeZhTw, 'zh-TW');
DeleteOrganizationComponent,
DomainRulesComponent,
DownloadLicenseComponent,
EmergencyAccessAddEditComponent,
EmergencyAccessComponent,
EmergencyAccessConfirmComponent,
EmergencyAccessTakeoverComponent,
EmergencyAccessViewComponent,
EmergencyAddEditComponent,
ExportComponent,
ExposedPasswordsReportComponent,
FallbackSrcDirective,
@ -409,6 +423,10 @@ registerLocaleData(localeZhTw, 'zh-TW');
DeauthorizeSessionsComponent,
DeleteAccountComponent,
DeleteOrganizationComponent,
EmergencyAccessAddEditComponent,
EmergencyAccessConfirmComponent,
EmergencyAccessTakeoverComponent,
EmergencyAddEditComponent,
FolderAddEditComponent,
ModalComponent,
OrgAddEditComponent,

View File

@ -16,10 +16,14 @@ import {
ChangePasswordComponent as BaseChangePasswordComponent,
} from 'jslib/angular/components/change-password.component';
import { EmergencyAccessStatusType } from 'jslib/enums/emergencyAccessStatusType';
import { Utils } from 'jslib/misc/utils';
import { CipherString } from 'jslib/models/domain/cipherString';
import { SymmetricCryptoKey } from 'jslib/models/domain/symmetricCryptoKey';
import { CipherWithIdRequest } from 'jslib/models/request/cipherWithIdRequest';
import { EmergencyAccessUpdateRequest } from 'jslib/models/request/emergencyAccessUpdateRequest';
import { FolderWithIdRequest } from 'jslib/models/request/folderWithIdRequest';
import { PasswordRequest } from 'jslib/models/request/passwordRequest';
import { UpdateKeyRequest } from 'jslib/models/request/updateKeyRequest';
@ -160,5 +164,32 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent {
}
await this.apiService.postAccountKey(request);
await this.updateEmergencyAccesses(encKey[0]);
}
private async updateEmergencyAccesses(encKey: SymmetricCryptoKey) {
const emergencyAccess = await this.apiService.getEmergencyAccessTrusted();
const allowedStatuses = [
EmergencyAccessStatusType.Confirmed,
EmergencyAccessStatusType.RecoveryInitiated,
EmergencyAccessStatusType.RecoveryApproved,
];
const filteredAccesses = emergencyAccess.data.filter(d => allowedStatuses.includes(d.status));
for (const details of filteredAccesses) {
const publicKeyResponse = await this.apiService.getUserPublicKey(details.granteeId);
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
const encryptedKey = await this.cryptoService.rsaEncrypt(encKey.key, publicKey.buffer);
const updateRequest = new EmergencyAccessUpdateRequest();
updateRequest.type = details.type;
updateRequest.waitTimeDays = details.waitTimeDays;
updateRequest.keyEncrypted = encryptedKey.encryptedString;
await this.apiService.putEmergencyAccess(details.id, updateRequest);
}
}
}

View File

@ -0,0 +1,76 @@
<div class="modal fade" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="userAddEditTitle">
<div class="modal-dialog modal-lg" role="document">
<form class="modal-content" #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate>
<div class="modal-header">
<h2 class="modal-title" id="userAddEditTitle">
<span class="badge badge-primary" *ngIf="readOnly">{{'premium' | i18n}}</span>
{{title}}
<small class="text-muted" *ngIf="name">{{name}}</small>
</h2>
<button type="button" class="close" data-dismiss="modal" appA11yTitle="{{'close' | i18n}}">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body" *ngIf="loading">
<i class="fa fa-spinner fa-spin text-muted" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span class="sr-only">{{'loading' | i18n}}</span>
</div>
<div class="modal-body" *ngIf="!loading">
<ng-container *ngIf="!editMode">
<p>{{'inviteEmergencyContactDesc' | i18n}}</p>
<div class="form-group mb-4">
<label for="email">{{'email' | i18n}}</label>
<input id="email" class="form-control" type="text" name="Email" [(ngModel)]="email" required>
</div>
</ng-container>
<h3>
{{'userAccess' | i18n}}
<a target="_blank" rel="noopener" appA11yTitle="{{'learnMore' | i18n}}"
href="https://bitwarden.com/help/article/user-types-access-control/#user-types">
<i class="fa fa-question-circle-o" aria-hidden="true"></i>
</a>
</h3>
<div class="form-check mt-2 form-check-block">
<input class="form-check-input" type="radio" name="userType" id="emergencyTypeView"
[value]="emergencyAccessType.View" [(ngModel)]="type">
<label class="form-check-label" for="emergencyTypeView">
{{'view' | i18n}}
<small>{{'viewDesc' | i18n}}</small>
</label>
</div>
<div class="form-check mt-2 form-check-block">
<input class="form-check-input" type="radio" name="userType" id="emergencyTypeTakeover"
[value]="emergencyAccessType.Takeover" [(ngModel)]="type" [disabled]="readOnly">
<label class="form-check-label" for="emergencyTypeTakeover">
{{'takeover' | i18n}}
<small>{{'takeoverDesc' | i18n}}</small>
</label>
</div>
<div class="form-group col-6 mt-4">
<label for="waitTime">{{'waitTime' | i18n}}</label>
<select id="waitTime" name="waitTime" [(ngModel)]="waitTime" class="form-control" [disabled]="readOnly">
<option *ngFor="let o of waitTimes" [ngValue]="o.value">{{o.name}}</option>
</select>
<small class="text-muted">{{'waitTimeDesc' | i18n}}</small>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary" [disabled]="loading || readOnly">
<i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}" aria-hidden="true" *ngIf="loading"></i>
<span *ngIf="!loading">{{'save' | i18n}}</span>
</button>
<button type="button" class="btn btn-outline-secondary"
data-dismiss="modal">{{'cancel' | i18n}}</button>
<div class="ml-auto">
<button #deleteBtn type="button" (click)="delete()" class="btn btn-outline-danger"
appA11yTitle="{{'delete' | i18n}}" *ngIf="editMode" [disabled]="deleteBtn.loading"
[appApiAction]="deletePromise">
<i class="fa fa-trash-o fa-lg fa-fw" [hidden]="deleteBtn.loading" aria-hidden="true"></i>
<i class="fa fa-spinner fa-spin fa-lg fa-fw" [hidden]="!deleteBtn.loading"
title="{{'loading' | i18n}}" aria-hidden="true"></i>
</button>
</div>
</div>
</form>
</div>
</div>

View File

@ -0,0 +1,99 @@
import {
Component,
EventEmitter,
Input,
OnInit,
Output,
} from '@angular/core';
import { ToasterService } from 'angular2-toaster';
import { ApiService } from 'jslib/abstractions/api.service';
import { I18nService } from 'jslib/abstractions/i18n.service';
import { EmergencyAccessType } from 'jslib/enums/emergencyAccessType';
import { EmergencyAccessInviteRequest } from 'jslib/models/request/emergencyAccessInviteRequest';
import { EmergencyAccessUpdateRequest } from 'jslib/models/request/emergencyAccessUpdateRequest';
@Component({
selector: 'emergency-access-add-edit',
templateUrl: 'emergency-access-add-edit.component.html',
})
export class EmergencyAccessAddEditComponent implements OnInit {
@Input() name: string;
@Input() emergencyAccessId: string;
@Output() onSaved = new EventEmitter();
@Output() onDeleted = new EventEmitter();
loading = true;
readOnly: boolean = false;
editMode: boolean = false;
title: string;
email: string;
type: EmergencyAccessType = EmergencyAccessType.View;
formPromise: Promise<any>;
emergencyAccessType = EmergencyAccessType;
waitTimes: { name: string; value: number; }[];
waitTime: number;
constructor(private apiService: ApiService, private i18nService: I18nService,
private toasterService: ToasterService) { }
async ngOnInit() {
this.editMode = this.loading = this.emergencyAccessId != null;
this.waitTimes = [
{ name: this.i18nService.t('oneDay'), value: 1 },
{ name: this.i18nService.t('days', '2'), value: 2 },
{ name: this.i18nService.t('days', '7'), value: 7 },
{ name: this.i18nService.t('days', '14'), value: 14 },
{ name: this.i18nService.t('days', '30'), value: 30 },
{ name: this.i18nService.t('days', '90'), value: 90 },
];
if (this.editMode) {
this.editMode = true;
this.title = this.i18nService.t('editEmergencyContact');
try {
const emergencyAccess = await this.apiService.getEmergencyAccess(this.emergencyAccessId);
this.type = emergencyAccess.type;
this.waitTime = emergencyAccess.waitTimeDays;
} catch { }
} else {
this.title = this.i18nService.t('inviteEmergencyContact');
this.waitTime = this.waitTimes[2].value;
}
this.loading = false;
}
async submit() {
try {
if (this.editMode) {
const request = new EmergencyAccessUpdateRequest();
request.type = this.type;
request.waitTimeDays = this.waitTime;
this.formPromise = this.apiService.putEmergencyAccess(this.emergencyAccessId, request);
} else {
const request = new EmergencyAccessInviteRequest();
request.email = this.email.trim();
request.type = this.type;
request.waitTimeDays = this.waitTime;
this.formPromise = this.apiService.postEmergencyAccessInvite(request);
}
await this.formPromise;
this.toasterService.popAsync('success', null,
this.i18nService.t(this.editMode ? 'editedUserId' : 'invitedUsers', this.name));
this.onSaved.emit();
} catch { }
}
async delete() {
this.onDeleted.emit();
}
}

View File

@ -0,0 +1,38 @@
<div class="modal fade" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="confirmUserTitle">
<div class="modal-dialog" role="document">
<form class="modal-content" #form (ngSubmit)="submit()" [appApiAction]="formPromise">
<div class="modal-header">
<h2 class="modal-title" id="confirmUserTitle">
{{'confirmUser' | i18n}}
<small class="text-muted" *ngIf="name">{{name}}</small>
</h2>
<button type="button" class="close" data-dismiss="modal" appA11yTitle="{{'close' | i18n}}">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>
{{'fingerprintEnsureIntegrityVerify' | i18n}}
<a href="https://help.bitwarden.com/article/fingerprint-phrase/" target="_blank" rel="noopener">
{{'learnMore' | i18n}}</a>
</p>
<p><code>{{fingerprint}}</code></p>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="dontAskAgain" name="DontAskAgain"
[(ngModel)]="dontAskAgain">
<label class="form-check-label" for="dontAskAgain">
{{'dontAskFingerprintAgain' | i18n}}
</label>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span>{{'confirm' | i18n}}</span>
</button>
<button type="button" class="btn btn-outline-secondary"
data-dismiss="modal">{{'cancel' | i18n}}</button>
</div>
</form>
</div>
</div>

View File

@ -0,0 +1,61 @@
import {
Component,
EventEmitter,
Input,
OnInit,
Output,
} from '@angular/core';
import { ConstantsService } from 'jslib/services/constants.service';
import { ApiService } from 'jslib/abstractions/api.service';
import { CryptoService } from 'jslib/abstractions/crypto.service';
import { StorageService } from 'jslib/abstractions/storage.service';
import { Utils } from 'jslib/misc/utils';
@Component({
selector: 'emergency-access-confirm',
templateUrl: 'emergency-access-confirm.component.html',
})
export class EmergencyAccessConfirmComponent implements OnInit {
@Input() name: string;
@Input() userId: string;
@Input() emergencyAccessId: string;
@Output() onConfirmed = new EventEmitter();
dontAskAgain = false;
loading = true;
fingerprint: string;
constructor(private apiService: ApiService, private cryptoService: CryptoService,
private storageService: StorageService) { }
async ngOnInit() {
try {
const publicKeyResponse = await this.apiService.getUserPublicKey(this.userId);
if (publicKeyResponse != null) {
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
const fingerprint = await this.cryptoService.getFingerprint(this.userId, publicKey.buffer);
if (fingerprint != null) {
this.fingerprint = fingerprint.join('-');
}
}
} catch { }
this.loading = false;
}
async submit() {
if (this.loading) {
return;
}
if (this.dontAskAgain) {
await this.storageService.save(ConstantsService.autoConfirmFingerprints, true);
}
try {
this.onConfirmed.emit();
} catch { }
}
}

View File

@ -0,0 +1,44 @@
<div class="modal fade" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="userAddEditTitle">
<div class="modal-dialog modal-lg" role="document">
<form class="modal-content" #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate>
<div class="modal-header">
<h2 class="modal-title" id="userAddEditTitle">
{{'takeover' | i18n}}
<small class="text-muted" *ngIf="name">{{name}}</small>
</h2>
<button type="button" class="close" data-dismiss="modal" appA11yTitle="{{'close' | i18n}}">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<app-callout type="warning">{{'loggedOutWarning' | i18n}}</app-callout>
<div class="row">
<div class="col-6">
<div class="form-group">
<label for="masterPassword">{{'newMasterPass' | i18n}}</label>
<input id="masterPassword" type="password" name="NewMasterPasswordHash" class="form-control mb-1"
[(ngModel)]="masterPassword" (input)="updatePasswordStrength()" required appInputVerbatim
autocomplete="new-password">
<app-password-strength [score]="masterPasswordScore" [showText]="true"></app-password-strength>
</div>
</div>
<div class="col-6">
<div class="form-group">
<label for="masterPasswordRetype">{{'confirmNewMasterPass' | i18n}}</label>
<input id="masterPasswordRetype" type="password" name="MasterPasswordRetype"
class="form-control" [(ngModel)]="masterPasswordRetype" required appInputVerbatim
autocomplete="new-password">
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span>{{'save' | i18n}}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">{{'cancel' | i18n}}</button>
</div>
</form>
</div>
</div>

View File

@ -0,0 +1,81 @@
import {
Component,
EventEmitter,
Input,
OnInit,
Output,
} from '@angular/core';
import { ToasterService } from 'angular2-toaster';
import { ApiService } from 'jslib/abstractions/api.service';
import { CryptoService } from 'jslib/abstractions/crypto.service';
import { I18nService } from 'jslib/abstractions/i18n.service';
import { MessagingService } from 'jslib/abstractions/messaging.service';
import { PasswordGenerationService } from 'jslib/abstractions/passwordGeneration.service';
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
import { PolicyService } from 'jslib/abstractions/policy.service';
import { UserService } from 'jslib/abstractions/user.service';
import { ChangePasswordComponent } from 'jslib/angular/components/change-password.component';
import { KdfType } from 'jslib/enums/kdfType';
import { SymmetricCryptoKey } from 'jslib/models/domain/symmetricCryptoKey';
import { EmergencyAccessPasswordRequest } from 'jslib/models/request/emergencyAccessPasswordRequest';
@Component({
selector: 'emergency-access-takeover',
templateUrl: 'emergency-access-takeover.component.html',
})
export class EmergencyAccessTakeoverComponent extends ChangePasswordComponent implements OnInit {
@Output() onDone = new EventEmitter();
@Input() emergencyAccessId: string;
@Input() name: string;
@Input() email: string;
@Input() kdf: KdfType;
@Input() kdfIterations: number;
formPromise: Promise<any>;
constructor(i18nService: I18nService, cryptoService: CryptoService,
messagingService: MessagingService, userService: UserService,
passwordGenerationService: PasswordGenerationService,
platformUtilsService: PlatformUtilsService, policyService: PolicyService,
private apiService: ApiService, private toasterService: ToasterService) {
super(i18nService, cryptoService, messagingService, userService, passwordGenerationService,
platformUtilsService, policyService);
}
// tslint:disable-next-line
async ngOnInit() { }
async submit() {
if (!await this.strongPassword()) {
return;
}
const takeoverResponse = await this.apiService.postEmergencyAccessTakeover(this.emergencyAccessId);
const oldKeyBuffer = await this.cryptoService.rsaDecrypt(takeoverResponse.keyEncrypted);
const oldEncKey = new SymmetricCryptoKey(oldKeyBuffer);
if (oldEncKey == null) {
this.toasterService.popAsync('error', this.i18nService.t('errorOccurred'), this.i18nService.t('unexpectedError'));
return;
}
const key = await this.cryptoService.makeKey(this.masterPassword, this.email, takeoverResponse.kdf, takeoverResponse.kdfIterations);
const masterPasswordHash = await this.cryptoService.hashPassword(this.masterPassword, key);
const encKey = await this.cryptoService.remakeEncKey(key, oldEncKey);
const request = new EmergencyAccessPasswordRequest();
request.newMasterPasswordHash = masterPasswordHash;
request.key = encKey[1].encryptedString;
this.apiService.postEmergencyAccessPassword(this.emergencyAccessId, request);
try {
this.onDone.emit();
} catch { }
}
}

View File

@ -0,0 +1,31 @@
<div class="page-header">
<h1>{{'vault' | i18n}}</h1>
</div>
<div class="mt-4">
<ng-container *ngIf="ciphers.length">
<table class="table table-hover table-list table-ciphers">
<tbody>
<tr *ngFor="let c of ciphers">
<td class="table-list-icon">
<app-vault-icon [cipher]="c"></app-vault-icon>
</td>
<td class="reduced-lh wrap">
<a href="#" appStopClick (click)="selectCipher(c)" title="{{'editItem' | i18n}}">{{c.name}}</a>
<ng-container *ngIf="!organization && c.organizationId">
<i class="fa fa-share-alt" appStopProp title="{{'shared' | i18n}}" aria-hidden="true"></i>
<span class="sr-only">{{'shared' | i18n}}</span>
</ng-container>
<ng-container *ngIf="c.hasAttachments">
<i class="fa fa-paperclip" appStopProp title="{{'attachments' | i18n}}"
aria-hidden="true"></i>
<span class="sr-only">{{'attachments' | i18n}}</span>
</ng-container>
<br>
<small>{{c.subTitle}}</small>
</td>
</tr>
</tbody>
</table>
</ng-container>
</div>
<ng-template #cipherAddEdit></ng-template>

View File

@ -0,0 +1,94 @@
import {
Component,
ComponentFactoryResolver,
OnInit,
ViewChild,
ViewContainerRef,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ApiService } from 'jslib/abstractions/api.service';
import { CipherService } from 'jslib/abstractions/cipher.service';
import { CryptoService } from 'jslib/abstractions/crypto.service';
import { CipherData } from 'jslib/models/data';
import { Cipher, SymmetricCryptoKey } from 'jslib/models/domain';
import { EmergencyAccessViewResponse } from 'jslib/models/response/emergencyAccessResponse';
import { CipherView } from 'jslib/models/view/cipherView';
import { ModalComponent } from '../modal.component';
import { EmergencyAddEditComponent } from './emergency-add-edit.component';
@Component({
selector: 'emergency-access-view',
templateUrl: 'emergency-access-view.component.html',
})
export class EmergencyAccessViewComponent implements OnInit {
@ViewChild('cipherAddEdit', { read: ViewContainerRef, static: true }) cipherAddEditModalRef: ViewContainerRef;
id: string;
ciphers: CipherView[] = [];
private modal: ModalComponent = null;
constructor(private cipherService: CipherService, private cryptoService: CryptoService,
private componentFactoryResolver: ComponentFactoryResolver, private router: Router,
private route: ActivatedRoute, private apiService: ApiService) { }
ngOnInit() {
this.route.params.subscribe((qParams) => {
if (qParams.id == null) {
return this.router.navigate(['settings/emergency-access']);
}
this.id = qParams.id;
this.load();
});
}
selectCipher(cipher: CipherView) {
if (this.modal != null) {
this.modal.close();
}
const factory = this.componentFactoryResolver.resolveComponentFactory(ModalComponent);
this.modal = this.cipherAddEditModalRef.createComponent(factory).instance;
const childComponent = this.modal.show<EmergencyAddEditComponent>(EmergencyAddEditComponent, this.cipherAddEditModalRef);
childComponent.cipherId = cipher == null ? null : cipher.id;
childComponent.cipher = cipher;
this.modal.onClosed.subscribe(() => {
this.modal = null;
});
return childComponent;
}
async load() {
const response = await this.apiService.postEmergencyAccessView(this.id);
this.ciphers = await this.getAllCiphers(response);
}
protected async getAllCiphers(response: EmergencyAccessViewResponse): Promise<CipherView[]> {
const ciphers = response.ciphers;
const decCiphers: CipherView[] = [];
const oldKeyBuffer = await this.cryptoService.rsaDecrypt(response.keyEncrypted);
const oldEncKey = new SymmetricCryptoKey(oldKeyBuffer);
const promises: any[] = [];
ciphers.forEach((cipherResponse) => {
const cipherData = new CipherData(cipherResponse);
const cipher = new Cipher(cipherData);
promises.push(cipher.decrypt(oldEncKey).then((c) => decCiphers.push(c)));
});
await Promise.all(promises);
decCiphers.sort(this.cipherService.getLocaleSortingFunction());
return decCiphers;
}
}

View File

@ -0,0 +1,159 @@
<div class="page-header">
<h1>{{'emergencyAccess' | i18n}}</h1>
</div>
<p>
{{'emergencyAccessDesc' | i18n}}
<a href="https://help.bitwarden.com/article/fingerprint-phrase/" target="_blank" rel="noopener">
{{'learnMore' | i18n}}.
</a>
</p>
<div class="page-header d-flex">
<h2>
{{'trustedEmergencyContacts' | i18n}}
<a href="#" appStopClick class="badge badge-primary" *ngIf="!canAccessPremium" (click)="premiumRequired()">
{{'premium' | i18n}}
</a>
</h2>
<div class="ml-auto d-flex">
<button class="btn btn-sm btn-outline-primary ml-3" type="button" (click)="invite()" [disabled]="!canAccessPremium">
<i aria-hidden="true" class="fa fa-plus fa-fw"></i>
{{'addEmergencyContact' |i18n}}
</button>
</div>
</div>
<table class="table table-hover table-list mb-0" *ngIf="trustedContacts && trustedContacts.length">
<tbody>
<tr *ngFor="let c of trustedContacts; let i = index">
<td width="30">
<app-avatar [data]="c.name || c.email" [email]="c.email" size="25" [circle]="true"
[fontSize]="14"></app-avatar>
</td>
<td>
<a href="#" appStopClick (click)="edit(c)">{{c.email}}</a>
<span class="badge badge-secondary"
*ngIf="c.status === emergencyAccessStatusType.Invited">{{'invited' | i18n}}</span>
<span class="badge badge-warning"
*ngIf="c.status === emergencyAccessStatusType.Accepted">{{'accepted' | i18n}}</span>
<span class="badge badge-warning"
*ngIf="c.status === emergencyAccessStatusType.RecoveryInitiated">{{'emergencyAccessRecoveryInitiated' | i18n}}</span>
<span class="badge badge-success"
*ngIf="c.status === emergencyAccessStatusType.RecoveryApproved">{{'emergencyAccessRecoveryApproved' | i18n}}</span>
<span class="badge badge-primary"
*ngIf="c.type === emergencyAccessType.View">{{'view' | i18n}}</span>
<span class="badge badge-primary"
*ngIf="c.type === emergencyAccessType.Takeover">{{'takeover' | i18n}}</span>
<small class="text-muted d-block" *ngIf="c.name">{{c.name}}</small>
</td>
<td class="table-list-options">
<div class="dropdown" appListDropdown>
<button class="btn btn-outline-secondary dropdown-toggle" type="button"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
appA11yTitle="{{'options' | i18n}}">
<i class="fa fa-cog fa-lg" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item" href="#" appStopClick (click)="reinvite(c)"
*ngIf="c.status === emergencyAccessStatusType.Invited">
<i class="fa fa-fw fa-envelope-o" aria-hidden="true"></i>
{{'resendInvitation' | i18n}}
</a>
<a class="dropdown-item text-success" href="#" appStopClick (click)="confirm(c)"
*ngIf="c.status === emergencyAccessStatusType.Accepted">
<i class="fa fa-fw fa-check" aria-hidden="true"></i>
{{'confirm' | i18n}}
</a>
<a class="dropdown-item text-success" href="#" appStopClick (click)="approve(c)"
*ngIf="c.status === emergencyAccessStatusType.RecoveryInitiated">
<i class="fa fa-fw fa-check" aria-hidden="true"></i>
{{'approve' | i18n}}
</a>
<a class="dropdown-item text-warning" href="#" appStopClick (click)="reject(c)"
*ngIf="c.status === emergencyAccessStatusType.RecoveryInitiated || c.status === emergencyAccessStatusType.RecoveryApproved">
<i class="fa fa-fw fa-remove" aria-hidden="true"></i>
{{'reject' | i18n}}
</a>
<a class="dropdown-item text-danger" href="#" appStopClick (click)="remove(c)">
<i class="fa fa-fw fa-remove" aria-hidden="true"></i>
{{'remove' | i18n}}
</a>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<p *ngIf="!trustedContacts || !trustedContacts.length">{{'noTrustedContacts' | i18n}}</p>
<div class="page-header spaced-header">
<h2>{{'designatedEmergencyContacts' | i18n}}</h2>
</div>
<table class="table table-hover table-list mb-0" *ngIf="grantedContacts && grantedContacts.length">
<tbody>
<tr *ngFor="let c of grantedContacts; let i = index">
<td width="30">
<app-avatar [data]="c.name || c.email" [email]="c.email" size="25" [circle]="true"
[fontSize]="14"></app-avatar>
</td>
<td>
<span>{{c.email}}</span>
<span class="badge badge-secondary"
*ngIf="c.status === emergencyAccessStatusType.Invited">{{'invited' | i18n}}</span>
<span class="badge badge-warning"
*ngIf="c.status === emergencyAccessStatusType.Accepted">{{'accepted' | i18n}}</span>
<span class="badge badge-warning"
*ngIf="c.status === emergencyAccessStatusType.RecoveryInitiated">{{'emergencyAccessRecoveryInitiated' | i18n}}</span>
<span class="badge badge-success"
*ngIf="c.status === emergencyAccessStatusType.RecoveryApproved">{{'emergencyAccessRecoveryApproved' | i18n}}</span>
<span class="badge badge-primary"
*ngIf="c.type === emergencyAccessType.View">{{'view' | i18n}}</span>
<span class="badge badge-primary"
*ngIf="c.type === emergencyAccessType.Takeover">{{'takeover' | i18n}}</span>
<small class="text-muted d-block" *ngIf="c.name">{{c.name}}</small>
</td>
<td class="table-list-options">
<div class="dropdown" appListDropdown>
<button class="btn btn-outline-secondary dropdown-toggle" type="button"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
appA11yTitle="{{'options' | i18n}}">
<i class="fa fa-cog fa-lg" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item" href="#" appStopClick (click)="requestAccess(c)"
*ngIf="c.status === emergencyAccessStatusType.Confirmed">
<i class="fa fa-fw fa-envelope-o" aria-hidden="true"></i>
{{'requestAccess' | i18n}}
</a>
<a class="dropdown-item" href="#" appStopClick (click)="takeover(c)"
*ngIf="c.status === emergencyAccessStatusType.RecoveryApproved && c.type === emergencyAccessType.Takeover">
<i class="fa fa-fw fa-key" aria-hidden="true"></i>
{{'takeover' | i18n}}
</a>
<a class="dropdown-item" [routerLink]="c.id"
*ngIf="c.status === emergencyAccessStatusType.RecoveryApproved && c.type === emergencyAccessType.View">
<i class="fa fa-fw fa-eye" aria-hidden="true"></i>
{{'view' | i18n}}
</a>
<a class="dropdown-item text-danger" href="#" appStopClick (click)="remove(c)">
<i class="fa fa-fw fa-remove" aria-hidden="true"></i>
{{'remove' | i18n}}
</a>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<p *ngIf="!grantedContacts || !grantedContacts.length">{{'noGrantedAccess' | i18n}}</p>
<ng-template #addEdit></ng-template>
<ng-template #takeoverTemplate></ng-template>
<ng-template #confirmTemplate></ng-template>

View File

@ -0,0 +1,274 @@
import { Component, ComponentFactoryResolver, OnInit, ViewChild, ViewContainerRef } from '@angular/core';
import { ToasterService } from 'angular2-toaster';
import { ApiService } from 'jslib/abstractions/api.service';
import { CryptoService } from 'jslib/abstractions/crypto.service';
import { I18nService } from 'jslib/abstractions/i18n.service';
import { MessagingService } from 'jslib/abstractions/messaging.service';
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
import { StorageService } from 'jslib/abstractions/storage.service';
import { UserService } from 'jslib/abstractions/user.service';
import { EmergencyAccessStatusType } from 'jslib/enums/emergencyAccessStatusType';
import { EmergencyAccessType } from 'jslib/enums/emergencyAccessType';
import { Utils } from 'jslib/misc/utils';
import { EmergencyAccessConfirmRequest } from 'jslib/models/request/emergencyAccessConfirmRequest';
import { EmergencyAccessGranteeDetailsResponse, EmergencyAccessGrantorDetailsResponse } from 'jslib/models/response/emergencyAccessResponse';
import { ConstantsService } from 'jslib/services/constants.service';
import { ModalComponent } from '../modal.component';
import { EmergencyAccessAddEditComponent } from './emergency-access-add-edit.component';
import { EmergencyAccessConfirmComponent } from './emergency-access-confirm.component';
import { EmergencyAccessTakeoverComponent } from './emergency-access-takeover.component';
@Component({
selector: 'emergency-access',
templateUrl: 'emergency-access.component.html',
})
export class EmergencyAccessComponent implements OnInit {
@ViewChild('addEdit', { read: ViewContainerRef, static: true }) addEditModalRef: ViewContainerRef;
@ViewChild('takeoverTemplate', { read: ViewContainerRef, static: true}) takeoverModalRef: ViewContainerRef;
@ViewChild('confirmTemplate', { read: ViewContainerRef, static: true }) confirmModalRef: ViewContainerRef;
canAccessPremium: boolean;
trustedContacts: EmergencyAccessGranteeDetailsResponse[];
grantedContacts: EmergencyAccessGrantorDetailsResponse[];
emergencyAccessType = EmergencyAccessType;
emergencyAccessStatusType = EmergencyAccessStatusType;
actionPromise: Promise<any>;
private modal: ModalComponent = null;
constructor(private apiService: ApiService, private i18nService: I18nService,
private componentFactoryResolver: ComponentFactoryResolver,
private platformUtilsService: PlatformUtilsService,
private toasterService: ToasterService, private cryptoService: CryptoService,
private storageService: StorageService, private userService: UserService,
private messagingService: MessagingService) { }
async ngOnInit() {
this.canAccessPremium = await this.userService.canAccessPremium();
this.load();
}
async load() {
this.trustedContacts = (await this.apiService.getEmergencyAccessTrusted()).data;
this.grantedContacts = (await this.apiService.getEmergencyAccessGranted()).data;
}
async premiumRequired() {
if (!this.canAccessPremium) {
this.messagingService.send('premiumRequired');
return;
}
}
edit(details: EmergencyAccessGranteeDetailsResponse) {
if (this.modal != null) {
this.modal.close();
}
const factory = this.componentFactoryResolver.resolveComponentFactory(ModalComponent);
this.modal = this.addEditModalRef.createComponent(factory).instance;
const childComponent = this.modal.show<EmergencyAccessAddEditComponent>(
EmergencyAccessAddEditComponent, this.addEditModalRef);
childComponent.name = details?.name ?? details?.email;
childComponent.emergencyAccessId = details?.id;
childComponent.readOnly = !this.canAccessPremium;
childComponent.onSaved.subscribe(() => {
this.modal.close();
this.load();
});
childComponent.onDeleted.subscribe(() => {
this.modal.close();
this.remove(details);
});
this.modal.onClosed.subscribe(() => {
this.modal = null;
});
}
invite() {
this.edit(null);
}
async reinvite(contact: EmergencyAccessGranteeDetailsResponse) {
if (this.actionPromise != null) {
return;
}
this.actionPromise = this.apiService.postEmergencyAccessReinvite(contact.id);
await this.actionPromise;
this.toasterService.popAsync('success', null, this.i18nService.t('hasBeenReinvited', contact.email));
this.actionPromise = null;
}
async confirm(contact: EmergencyAccessGranteeDetailsResponse) {
function updateUser() {
contact.status = EmergencyAccessStatusType.Confirmed;
}
if (this.actionPromise != null) {
return;
}
const autoConfirm = await this.storageService.get<boolean>(ConstantsService.autoConfirmFingerprints);
if (autoConfirm == null || !autoConfirm) {
if (this.modal != null) {
this.modal.close();
}
const factory = this.componentFactoryResolver.resolveComponentFactory(ModalComponent);
this.modal = this.confirmModalRef.createComponent(factory).instance;
const childComponent = this.modal.show<EmergencyAccessConfirmComponent>(
EmergencyAccessConfirmComponent, this.confirmModalRef);
childComponent.name = contact?.name ?? contact?.email;
childComponent.emergencyAccessId = contact.id;
childComponent.userId = contact?.granteeId;
childComponent.onConfirmed.subscribe(async () => {
this.modal.close();
await this.doConfirmation(contact);
updateUser();
this.toasterService.popAsync('success', null, this.i18nService.t('hasBeenConfirmed', contact.name || contact.email));
});
this.modal.onClosed.subscribe(() => {
this.modal = null;
});
return;
}
this.actionPromise = this.doConfirmation(contact);
await this.actionPromise;
updateUser();
this.toasterService.popAsync('success', null, this.i18nService.t('hasBeenConfirmed', contact.name || contact.email));
this.actionPromise = null;
}
async remove(details: EmergencyAccessGranteeDetailsResponse | EmergencyAccessGrantorDetailsResponse) {
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t('removeUserConfirmation'), details.name || details.email,
this.i18nService.t('yes'), this.i18nService.t('no'), 'warning');
if (!confirmed) {
return false;
}
try {
await this.apiService.deleteEmergencyAccess(details.id);
this.toasterService.popAsync('success', null, this.i18nService.t('removedUserId', details.name || details.email));
if (details instanceof EmergencyAccessGranteeDetailsResponse) {
this.removeGrantee(details);
} else {
this.removeGrantor(details);
}
} catch { }
}
async requestAccess(details: EmergencyAccessGrantorDetailsResponse) {
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t('requestAccessConfirmation', details.waitTimeDays.toString()),
details.name || details.email,
this.i18nService.t('requestAccess'),
this.i18nService.t('no'),
'warning'
);
if (!confirmed) {
return false;
}
await this.apiService.postEmergencyAccessInitiate(details.id);
details.status = EmergencyAccessStatusType.RecoveryInitiated;
this.toasterService.popAsync('success', null, this.i18nService.t('requestSent', details.name || details.email));
}
async approve(details: EmergencyAccessGranteeDetailsResponse) {
const type = this.i18nService.t(details.type === EmergencyAccessType.View ? 'view' : 'takeover');
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t('approveAccessConfirmation', details.name, type),
details.name || details.email,
this.i18nService.t('approve'),
this.i18nService.t('no'),
'warning'
);
if (!confirmed) {
return false;
}
await this.apiService.postEmergencyAccessApprove(details.id);
details.status = EmergencyAccessStatusType.RecoveryApproved;
this.toasterService.popAsync('success', null, this.i18nService.t('emergencyApproved', details.name || details.email));
}
async reject(details: EmergencyAccessGranteeDetailsResponse) {
await this.apiService.postEmergencyAccessReject(details.id);
details.status = EmergencyAccessStatusType.Confirmed;
this.toasterService.popAsync('success', null, this.i18nService.t('emergencyRejected', details.name || details.email));
}
async takeover(details: EmergencyAccessGrantorDetailsResponse) {
if (this.modal != null) {
this.modal.close();
}
const factory = this.componentFactoryResolver.resolveComponentFactory(ModalComponent);
this.modal = this.addEditModalRef.createComponent(factory).instance;
const childComponent = this.modal.show<EmergencyAccessTakeoverComponent>(
EmergencyAccessTakeoverComponent, this.takeoverModalRef);
childComponent.name = details != null ? details.name || details.email : null;
childComponent.email = details.email;
childComponent.emergencyAccessId = details != null ? details.id : null;
childComponent.onDone.subscribe(() => {
this.modal.close();
this.toasterService.popAsync('success', null, this.i18nService.t('passwordResetFor', details.name || details.email));
});
this.modal.onClosed.subscribe(() => {
this.modal = null;
});
}
private removeGrantee(details: EmergencyAccessGranteeDetailsResponse) {
const index = this.trustedContacts.indexOf(details);
if (index > -1) {
this.trustedContacts.splice(index, 1);
}
}
private removeGrantor(details: EmergencyAccessGrantorDetailsResponse) {
const index = this.grantedContacts.indexOf(details);
if (index > -1) {
this.grantedContacts.splice(index, 1);
}
}
// Encrypt the master password hash using the grantees public key, and send it to bitwarden for escrow.
private async doConfirmation(details: EmergencyAccessGranteeDetailsResponse) {
const encKey = await this.cryptoService.getEncKey();
const publicKeyResponse = await this.apiService.getUserPublicKey(details.granteeId);
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
try {
// tslint:disable-next-line
console.log('User\'s fingerprint: ' +
(await this.cryptoService.getFingerprint(details.granteeId, publicKey.buffer)).join('-'));
} catch { }
const encryptedKey = await this.cryptoService.rsaEncrypt(encKey.key, publicKey.buffer);
const request = new EmergencyAccessConfirmRequest();
request.key = encryptedKey.encryptedString;
await this.apiService.postEmergencyAccessConfirm(details.id, request);
}
}

View File

@ -0,0 +1,47 @@
import { Component } from '@angular/core';
import { AuditService } from 'jslib/abstractions/audit.service';
import { CipherService } from 'jslib/abstractions/cipher.service';
import { CollectionService } from 'jslib/abstractions/collection.service';
import { EventService } from 'jslib/abstractions/event.service';
import { FolderService } from 'jslib/abstractions/folder.service';
import { I18nService } from 'jslib/abstractions/i18n.service';
import { MessagingService } from 'jslib/abstractions/messaging.service';
import { PasswordGenerationService } from 'jslib/abstractions/passwordGeneration.service';
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
import { PolicyService } from 'jslib/abstractions/policy.service';
import { StateService } from 'jslib/abstractions/state.service';
import { TotpService } from 'jslib/abstractions/totp.service';
import { UserService } from 'jslib/abstractions/user.service';
import { Cipher } from 'jslib/models/domain/cipher';
import { AddEditComponent as BaseAddEditComponent } from '../vault/add-edit.component';
@Component({
selector: 'app-org-vault-add-edit',
templateUrl: '../vault/add-edit.component.html',
})
export class EmergencyAddEditComponent extends BaseAddEditComponent {
originalCipher: Cipher = null;
viewOnly = true;
constructor(cipherService: CipherService, folderService: FolderService,
i18nService: I18nService, platformUtilsService: PlatformUtilsService,
auditService: AuditService, stateService: StateService,
userService: UserService, collectionService: CollectionService,
totpService: TotpService, passwordGenerationService: PasswordGenerationService,
messagingService: MessagingService, eventService: EventService, policyService: PolicyService) {
super(cipherService, folderService, i18nService, platformUtilsService, auditService, stateService,
userService, collectionService, totpService, passwordGenerationService, messagingService,
eventService, policyService);
}
async load() {
this.title = this.i18nService.t('viewItem');
}
protected async loadCipher() {
return Promise.resolve(this.originalCipher);
}
}

View File

@ -28,6 +28,9 @@
<a routerLink="domain-rules" class="list-group-item" routerLinkActive="active">
{{'domainRules' | i18n}}
</a>
<a routerLink="emergency-access" class="list-group-item" routerLinkActive="active">
{{'emergencyAccess' | i18n}}
</a>
</div>
</div>
</div>

View File

@ -9,7 +9,7 @@
</button>
</div>
<div class="modal-body" *ngIf="cipher">
<div class="row" *ngIf="!editMode">
<div class="row" *ngIf="!editMode && !viewOnly">
<div class="col-6 form-group">
<label for="type">{{'whatTypeOfItem' | i18n}}</label>
<select id="type" name="Type" [(ngModel)]="cipher.type" class="form-control"
@ -22,12 +22,12 @@
<div class="col-6 form-group">
<label for="name">{{'name' | i18n}}</label>
<input id="name" class="form-control" type="text" name="Name" [(ngModel)]="cipher.name"
required [disabled]="cipher.isDeleted">
required [disabled]="cipher.isDeleted || viewOnly">
</div>
<div class="col-6 form-group" *ngIf="!organization">
<label for="folder">{{'folder' | i18n}}</label>
<select id="folder" name="FolderId" [(ngModel)]="cipher.folderId" class="form-control"
[disabled]="cipher.isDeleted">
[disabled]="cipher.isDeleted || viewOnly">
<option *ngFor="let f of folders" [ngValue]="f.id">{{f.name}}</option>
</select>
</div>
@ -39,7 +39,7 @@
<label for="loginUsername">{{'username' | i18n}}</label>
<div class="input-group">
<input id="loginUsername" class="form-control" type="text" name="Login.Username"
[(ngModel)]="cipher.login.username" appInputVerbatim [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.login.username" appInputVerbatim [disabled]="cipher.isDeleted || viewOnly">
<div class="input-group-append" *ngIf="!cipher.isDeleted">
<button type="button" class="btn btn-outline-secondary"
appA11yTitle="{{'copyUsername' | i18n}}"
@ -52,7 +52,7 @@
<div class="col-6 form-group">
<div class="d-flex">
<label for="loginPassword">{{'password' | i18n}}</label>
<div class="ml-auto d-flex" *ngIf="!cipher.isDeleted">
<div class="ml-auto d-flex" *ngIf="!cipher.isDeleted && !viewOnly">
<a href="#" class="d-block mr-2" appStopClick
appA11yTitle="{{'generatePassword' | i18n}}" (click)="generatePassword()"
*ngIf="cipher.viewPassword">
@ -72,7 +72,7 @@
<input id="loginPassword" class="form-control text-monospace"
type="{{showPassword ? 'text' : 'password'}}" name="Login.Password"
[(ngModel)]="cipher.login.password" appInputVerbatim autocomplete="new-password"
[disabled]="cipher.isDeleted || !cipher.viewPassword">
[disabled]="cipher.isDeleted || !cipher.viewPassword || viewOnly">
<div class="input-group-append">
<button type="button" class="btn btn-outline-secondary"
appA11yTitle="{{'toggleVisibility' | i18n}}" (click)="togglePassword()"
@ -94,7 +94,7 @@
<div class="col-6 form-group">
<label for="loginTotp">{{'authenticatorKeyTotp' | i18n}}</label>
<input id="loginTotp" type="{{cipher.viewPassword ? 'text' : 'password'}}" name="Login.Totp" class="form-control text-monospace"
[(ngModel)]="cipher.login.totp" appInputVerbatim [disabled]="cipher.isDeleted || !cipher.viewPassword">
[(ngModel)]="cipher.login.totp" appInputVerbatim [disabled]="cipher.isDeleted || !cipher.viewPassword || viewOnly">
</div>
<div class="col-6 form-group totp d-flex align-items-end" [ngClass]="{'low': totpLow}">
<div *ngIf="!cipher.login.totp || !totpCode">
@ -137,7 +137,7 @@
<label for="loginUri{{i}}">{{'uriPosition' | i18n : (i + 1)}}</label>
<div class="input-group">
<input class="form-control" id="loginUri{{i}}" type="text"
name="Login.Uris[{{i}}].Uri" [(ngModel)]="u.uri" [disabled]="cipher.isDeleted"
name="Login.Uris[{{i}}].Uri" [(ngModel)]="u.uri" [disabled]="cipher.isDeleted || viewOnly"
placeholder="{{'ex' | i18n}} https://google.com" appInputVerbatim>
<div class="input-group-append">
<button type="button" class="btn btn-outline-secondary"
@ -166,19 +166,19 @@
<div class="d-flex">
<select class="form-control" id="loginUriMatch{{i}}" name="Login.Uris[{{i}}].Match"
[(ngModel)]="u.match" (change)="loginUriMatchChanged(u)"
[disabled]="cipher.isDeleted">
[disabled]="cipher.isDeleted || viewOnly">
<option *ngFor="let o of uriMatchOptions" [ngValue]="o.value">{{o.name}}
</option>
</select>
<button type="button" class="btn btn-link text-danger ml-2" (click)="removeUri(u)"
appA11yTitle="{{'remove' | i18n}}" *ngIf="!cipher.isDeleted">
appA11yTitle="{{'remove' | i18n}}" *ngIf="!cipher.isDeleted && !viewOnly">
<i class="fa fa-minus-circle fa-lg" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</ng-container>
<a href="#" appStopClick (click)="addUri()" class="d-inline-block mb-3" *ngIf="!cipher.isDeleted">
<a href="#" appStopClick (click)="addUri()" class="d-inline-block mb-3" *ngIf="!cipher.isDeleted && !viewOnly">
<i class="fa fa-plus-circle fa-fw" aria-hidden="true"></i> {{'newUri' | i18n}}
</a>
</ng-container>
@ -189,12 +189,12 @@
<label for="cardCardholderName">{{'cardholderName' | i18n}}</label>
<input id="cardCardholderName" class="form-control" type="text"
name="Card.CardCardholderName" [(ngModel)]="cipher.card.cardholderName"
[disabled]="cipher.isDeleted">
[disabled]="cipher.isDeleted || viewOnly">
</div>
<div class="col-6 form-group">
<label for="cardBrand">{{'brand' | i18n}}</label>
<select id="cardBrand" class="form-control" name="Card.Brand"
[(ngModel)]="cipher.card.brand" [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.card.brand" [disabled]="cipher.isDeleted || viewOnly">
<option *ngFor="let o of cardBrandOptions" [ngValue]="o.value">{{o.name}}</option>
</select>
</div>
@ -204,7 +204,7 @@
<label for="cardNumber">{{'number' | i18n}}</label>
<div class="input-group">
<input id="cardNumber" class="form-control" type="text" name="Card.Number"
[(ngModel)]="cipher.card.number" appInputVerbatim [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.card.number" appInputVerbatim [disabled]="cipher.isDeleted || viewOnly">
<div class="input-group-append">
<button type="button" class="btn btn-outline-secondary"
appA11yTitle="{{'copyNumber' | i18n}}"
@ -217,7 +217,7 @@
<div class="col form-group">
<label for="cardExpMonth">{{'expirationMonth' | i18n}}</label>
<select id="cardExpMonth" class="form-control" name="Card.ExpMonth"
[(ngModel)]="cipher.card.expMonth" [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.card.expMonth" [disabled]="cipher.isDeleted || viewOnly">
<option *ngFor="let o of cardExpMonthOptions" [ngValue]="o.value">{{o.name}}</option>
</select>
</div>
@ -225,7 +225,7 @@
<label for="cardExpYear">{{'expirationYear' | i18n}}</label>
<input id="cardExpYear" class="form-control" type="text" name="Card.ExpYear"
[(ngModel)]="cipher.card.expYear" placeholder="{{'ex' | i18n}} 2019"
[disabled]="cipher.isDeleted">
[disabled]="cipher.isDeleted || viewOnly">
</div>
</div>
<div class="row">
@ -235,7 +235,7 @@
<input id="cardCode" class="form-control text-monospace"
type="{{showCardCode ? 'text' : 'password'}}" name="Card.Code"
[(ngModel)]="cipher.card.code" appInputVerbatim autocomplete="new-password"
[disabled]="cipher.isDeleted">
[disabled]="cipher.isDeleted || viewOnly">
<div class="input-group-append">
<button type="button" class="btn btn-outline-secondary"
appA11yTitle="{{'toggleVisibility' | i18n}}" (click)="toggleCardCode()"
@ -259,7 +259,7 @@
<div class="col-4 form-group">
<label for="idTitle">{{'title' | i18n}}</label>
<select id="idTitle" class="form-control" name="Identity.Title"
[(ngModel)]="cipher.identity.title" [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.identity.title" [disabled]="cipher.isDeleted || viewOnly">
<option *ngFor="let o of identityTitleOptions" [ngValue]="o.value">{{o.name}}</option>
</select>
</div>
@ -268,107 +268,107 @@
<div class="col-4 form-group">
<label for="idFirstName">{{'firstName' | i18n}}</label>
<input id="idFirstName" class="form-control" type="text" name="Identity.FirstName"
[(ngModel)]="cipher.identity.firstName" [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.identity.firstName" [disabled]="cipher.isDeleted || viewOnly">
</div>
<div class="col-4 form-group">
<label for="idMiddleName">{{'middleName' | i18n}}</label>
<input id="idMiddleName" class="form-control" type="text" name="Identity.MiddleName"
[(ngModel)]="cipher.identity.middleName" [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.identity.middleName" [disabled]="cipher.isDeleted || viewOnly">
</div>
<div class="col-4 form-group">
<label for="idLastName">{{'lastName' | i18n}}</label>
<input id="idLastName" class="form-control" type="text" name="Identity.LastName"
[(ngModel)]="cipher.identity.lastName" [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.identity.lastName" [disabled]="cipher.isDeleted || viewOnly">
</div>
</div>
<div class="row">
<div class="col-4 form-group">
<label for="idUsername">{{'username' | i18n}}</label>
<input id="idUsername" class="form-control" type="text" name="Identity.Username"
[(ngModel)]="cipher.identity.username" appInputVerbatim [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.identity.username" appInputVerbatim [disabled]="cipher.isDeleted || viewOnly">
</div>
<div class="col-4 form-group">
<label for="idCompany">{{'company' | i18n}}</label>
<input id="idCompany" class="form-control" type="text" name="Identity.Company"
[(ngModel)]="cipher.identity.company" [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.identity.company" [disabled]="cipher.isDeleted || viewOnly">
</div>
</div>
<div class="row">
<div class="col-4 form-group">
<label for="idSsn">{{'ssn' | i18n}}</label>
<input id="idSsn" class="form-control" type="text" name="Identity.SSN"
[(ngModel)]="cipher.identity.ssn" appInputVerbatim [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.identity.ssn" appInputVerbatim [disabled]="cipher.isDeleted || viewOnly">
</div>
<div class="col-4 form-group">
<label for="idPassportNumber">{{'passportNumber' | i18n}}</label>
<input id="idPassportNumber" class="form-control" type="text" name="Identity.PassportNumber"
[(ngModel)]="cipher.identity.passportNumber" appInputVerbatim [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.identity.passportNumber" appInputVerbatim [disabled]="cipher.isDeleted || viewOnly">
</div>
<div class="col-4 form-group">
<label for="idLicenseNumber">{{'licenseNumber' | i18n}}</label>
<input id="idLicenseNumber" class="form-control" type="text" name="Identity.LicenseNumber"
[(ngModel)]="cipher.identity.licenseNumber" appInputVerbatim [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.identity.licenseNumber" appInputVerbatim [disabled]="cipher.isDeleted || viewOnly">
</div>
</div>
<div class="row">
<div class="col-6 form-group">
<label for="idEmail">{{'email' | i18n}}</label>
<input id="idEmail" class="form-control" type="text" name="Identity.Email"
[(ngModel)]="cipher.identity.email" appInputVerbatim [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.identity.email" appInputVerbatim [disabled]="cipher.isDeleted || viewOnly">
</div>
<div class="col-6 form-group">
<label for="idPhone">{{'phone' | i18n}}</label>
<input id="idPhone" class="form-control" type="text" name="Identity.Phone"
[(ngModel)]="cipher.identity.phone" [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.identity.phone" [disabled]="cipher.isDeleted || viewOnly">
</div>
</div>
<div class="row">
<div class="col-6 form-group">
<label for="idAddress1">{{'address1' | i18n}}</label>
<input id="idAddress1" class="form-control" type="text" name="Identity.Address1"
[(ngModel)]="cipher.identity.address1" [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.identity.address1" [disabled]="cipher.isDeleted || viewOnly">
</div>
<div class="col-6 form-group">
<label for="idAddress2">{{'address2' | i18n}}</label>
<input id="idAddress2" class="form-control" type="text" name="Identity.Address2"
[(ngModel)]="cipher.identity.address2" [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.identity.address2" [disabled]="cipher.isDeleted || viewOnly">
</div>
</div>
<div class="row">
<div class="col-6 form-group">
<label for="idAddress3">{{'address3' | i18n}}</label>
<input id="idAddress3" class="form-control" type="text" name="Identity.Address3"
[(ngModel)]="cipher.identity.address3" [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.identity.address3" [disabled]="cipher.isDeleted || viewOnly">
</div>
<div class="col-6 form-group">
<label for="idCity">{{'cityTown' | i18n}}</label>
<input id="idCity" class="form-control" type="text" name="Identity.City"
[(ngModel)]="cipher.identity.city" [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.identity.city" [disabled]="cipher.isDeleted || viewOnly">
</div>
</div>
<div class="row">
<div class="col-6 form-group">
<label for="idState">{{'stateProvince' | i18n}}</label>
<input id="idState" class="form-control" type="text" name="Identity.State"
[(ngModel)]="cipher.identity.state" [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.identity.state" [disabled]="cipher.isDeleted || viewOnly">
</div>
<div class="col-6 form-group">
<label for="idPostalCode">{{'zipPostalCode' | i18n}}</label>
<input id="idPostalCode" class="form-control" type="text" name="Identity.PostalCode"
[(ngModel)]="cipher.identity.postalCode" [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.identity.postalCode" [disabled]="cipher.isDeleted || viewOnly">
</div>
</div>
<div class="row">
<div class="col-6 form-group">
<label for="idCountry">{{'country' | i18n}}</label>
<input id="idCountry" class="form-control" type="text" name="Identity.Country"
[(ngModel)]="cipher.identity.country" [disabled]="cipher.isDeleted">
[(ngModel)]="cipher.identity.country" [disabled]="cipher.isDeleted || viewOnly">
</div>
</div>
</ng-container>
<div class="form-group">
<label for="notes">{{'notes' | i18n}}</label>
<textarea id="notes" name="Notes" rows="6" [(ngModel)]="cipher.notes" [disabled]="cipher.isDeleted"
<textarea id="notes" name="Notes" rows="6" [(ngModel)]="cipher.notes" [disabled]="cipher.isDeleted || viewOnly"
class="form-control"></textarea>
</div>
<h3 class="mt-4">{{'customFields' | i18n}}</h3>
@ -383,14 +383,14 @@
</a>
</div>
<input id="fieldName{{i}}" type="text" name="Field.Name{{i}}" [(ngModel)]="f.name"
class="form-control" appInputVerbatim [disabled]="cipher.isDeleted">
class="form-control" appInputVerbatim [disabled]="cipher.isDeleted || viewOnly">
</div>
<div class="col-7 form-group">
<label for="fieldValue{{i}}">{{'value' | i18n}}</label>
<div class="d-flex align-items-center">
<div class="input-group" *ngIf="f.type === fieldType.Text">
<input id="fieldValue{{i}}" class="form-control" type="text" name="Field.Value{{i}}"
[(ngModel)]="f.value" appInputVerbatim [disabled]="cipher.isDeleted">
[(ngModel)]="f.value" appInputVerbatim [disabled]="cipher.isDeleted || viewOnly">
<div class="input-group-append">
<button type="button" class="btn btn-outline-secondary"
appA11yTitle="{{'copyValue' | i18n}}"
@ -403,7 +403,7 @@
<input id="fieldValue{{i}}" type="{{f.showValue ? 'text' : 'password'}}"
name="Field.Value{{i}}" [(ngModel)]="f.value"
class="form-control text-monospace" appInputVerbatim
autocomplete="new-password" [disabled]="cipher.isDeleted || (!cipher.viewPassword && !f.newField)">
autocomplete="new-password" [disabled]="cipher.isDeleted || viewOnly || (!cipher.viewPassword && !f.newField)">
<div class="input-group-append">
<button type="button" class="btn btn-outline-secondary"
appA11yTitle="{{'toggleVisibility' | i18n}}" (click)="toggleFieldValue(f)"
@ -423,24 +423,24 @@
<div class="flex-fill">
<input id="fieldValue{{i}}" name="Field.Value{{i}}" type="checkbox"
[(ngModel)]="f.value" *ngIf="f.type === fieldType.Boolean" appTrueFalseValue
trueValue="true" falseValue="false" [disabled]="cipher.isDeleted">
trueValue="true" falseValue="false" [disabled]="cipher.isDeleted || viewOnly">
</div>
<button type="button" class="btn btn-link text-danger ml-2" (click)="removeField(f)"
appA11yTitle="{{'remove' | i18n}}" *ngIf="!cipher.isDeleted">
appA11yTitle="{{'remove' | i18n}}" *ngIf="!cipher.isDeleted && !viewOnly">
<i class="fa fa-minus-circle fa-lg" aria-hidden="true"></i>
</button>
<button type="button" class="btn btn-link text-muted cursor-move"
appA11yTitle="{{'dragToSort' | i18n}}" *ngIf="!cipher.isDeleted">
appA11yTitle="{{'dragToSort' | i18n}}" *ngIf="!cipher.isDeleted && !viewOnly">
<i class="fa fa-bars fa-lg" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</div>
<a href="#" appStopClick (click)="addField()" class="d-inline-block mb-2" *ngIf="!cipher.isDeleted">
<a href="#" appStopClick (click)="addField()" class="d-inline-block mb-2" *ngIf="!cipher.isDeleted && !viewOnly">
<i class="fa fa-plus-circle fa-fw" aria-hidden="true"></i> {{'newCustomField' | i18n}}
</a>
<div class="row" *ngIf="!cipher.isDeleted">
<div class="row" *ngIf="!cipher.isDeleted && !viewOnly">
<div class="col-5">
<label for="addFieldType" class="sr-only">{{'type' | i18n}}</label>
<select id="addFieldType" class="form-control" name="AddFieldType" [(ngModel)]="addFieldType">
@ -455,7 +455,7 @@
<label for="organizationId">{{'whoOwnsThisItem' | i18n}}</label>
<select id="organizationId" class="form-control" name="OrganizationId"
[(ngModel)]="cipher.organizationId" (change)="organizationChanged()"
[disabled]="cipher.isDeleted">
[disabled]="cipher.isDeleted || viewOnly">
<option *ngFor="let o of ownershipOptions" [ngValue]="o.value">{{o.name}}</option>
</select>
</div>
@ -469,7 +469,7 @@
<ng-container *ngIf="collections && collections.length">
<div class="form-check" *ngFor="let c of collections; let i = index">
<input class="form-check-input" type="checkbox" [(ngModel)]="c.checked"
id="collection-{{i}}" name="Collection[{{i}}].Checked" [disabled]="cipher.isDeleted">
id="collection-{{i}}" name="Collection[{{i}}].Checked" [disabled]="cipher.isDeleted || viewOnly">
<label class="form-check-label" for="collection-{{i}}">{{c.name}}</label>
</div>
</ng-container>
@ -500,14 +500,14 @@
</ng-container>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading" *ngIf="!viewOnly">
<i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span>{{(cipher?.isDeleted ? 'restore' : 'save') | i18n}}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{'cancel' | i18n}}
{{(viewOnly ? 'close' : 'cancel') | i18n}}
</button>
<div class="ml-auto" *ngIf="cipher">
<div class="ml-auto" *ngIf="cipher && !viewOnly">
<button *ngIf="!organization && !cipher.isDeleted" type="button" (click)="toggleFavorite()" class="btn btn-link"
appA11yTitle="{{(cipher.favorite ? 'unfavorite' : 'favorite') | i18n}}">
<i class="fa fa-lg" [ngClass]="{'fa-star': cipher.favorite, 'fa-star-o': !cipher.favorite}"

View File

@ -34,6 +34,7 @@ export class AddEditComponent extends BaseAddEditComponent {
showRevisionDate = false;
hasPasswordHistory = false;
viewingPasswordHistory = false;
viewOnly = false;
protected totpInterval: number;

View File

@ -347,6 +347,9 @@
"editItem": {
"message": "Edit Item"
},
"viewItem": {
"message": "View Item"
},
"ex": {
"message": "ex.",
"description": "Short abbreviation for 'example'."
@ -3368,6 +3371,145 @@
"message": "There are no Sends to list.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"emergencyAccess": {
"message": "Emergency Access"
},
"emergencyAccessDesc": {
"message": "Grant and manage emergency access for trusted contacts. Trusted contacts may request access to either View or Takeover your account in case of a emergency. Visit our help page for more information and details into how zero knowledge sharing works."
},
"trustedEmergencyContacts": {
"message": "Trusted emergency contacts"
},
"noTrustedContacts": {
"message": "You have not added any emergency contacts yet, invite a trusted contact to get started."
},
"addEmergencyContact": {
"message": "Add emergency contact"
},
"designatedEmergencyContacts": {
"message": "Designated as emergency contact"
},
"noGrantedAccess": {
"message": "You have not been designated as an emergency contact for anyone yet."
},
"inviteEmergencyContact": {
"message": "Invite emergency contact"
},
"editEmergencyContact": {
"message": "Edit emergency contact"
},
"inviteEmergencyContactDesc": {
"message": "Invite a new emergency contact by entering their Bitwarden account email address below. If they do not have a Bitwarden account already, they will be prompted to create a new account."
},
"emergencyAccessRecoveryInitiated": {
"message": "Emergency Access Initiated"
},
"emergencyAccessRecoveryApproved": {
"message": "Emergency Access Approved"
},
"viewDesc": {
"message": "Can view all items in your own vault."
},
"takeover": {
"message": "Takeover"
},
"takeoverDesc": {
"message": "Can reset your account with a new master password."
},
"waitTime": {
"message": "Wait Time"
},
"waitTimeDesc": {
"message": "Time required before automatically granting access."
},
"oneDay": {
"message": "1 Day"
},
"days": {
"message": "$DAYS$ Days",
"placeholders": {
"days": {
"content": "$1",
"example": "1"
}
}
},
"invitedUser": {
"message": "Invited user."
},
"acceptEmergencyAccess": {
"message": "You've been invited to become an emergency contact for the user listed above. To accept the invitation, you need to log in or create a new Bitwarden account."
},
"emergencyInviteAcceptFailed": {
"message": "Unable to accept invitation. Ask the user to send a new invitation."
},
"emergencyInviteAcceptFailedShort": {
"message": "Unable to accept invitation. $DESCRIPTION$",
"placeholders": {
"description": {
"content": "$1",
"example": "You must enable 2FA on your user account before you can join this organization."
}
}
},
"emergencyInviteAcceptedDesc": {
"message": "You can access the emergency options for this user after your identity has been confirmed. We'll send you an email when that happens."
},
"requestAccess": {
"message": "Request Access"
},
"requestAccessConfirmation": {
"message": "Are you sure you want to request emergency access? You will be provided access after $WAITTIME$ day(s) or whenever the user manually approves the request.",
"placeholders": {
"waittime": {
"content": "$1",
"example": "1"
}
}
},
"requestSent": {
"message": "Emergency access requested for $USER$. We'll notify you by email when it's possible to continue.",
"placeholders": {
"user": {
"content": "$1",
"example": "John Smith"
}
}
},
"approve": {
"message": "Approve"
},
"reject": {
"message": "Reject"
},
"approveAccessConfirmation": {
"message": "Are you sure you want to approve emergency access? This will allow $USER$ to $ACTION$ your account.",
"placeholders": {
"user": {
"content": "$1",
"example": "John Smith"
},
"action": {
"content": "$2",
"example": "View"
}
}
},
"emergencyApproved": {
"message": "Emergency access approved."
},
"emergencyRejected": {
"message": "Emergency access rejected"
},
"passwordResetFor": {
"message": "Password reset for $USER$. You can now login using the new password.",
"placeholders": {
"user": {
"content": "$1",
"example": "John Smith"
}
}
},
"personalOwnership": {
"message": "Personal Ownership"
},