Send initial implementation (#687)

* send work

* Bump version to 2.16.2 (#668)

* [SSO] New User Provision flow jslib update (f30d6f8 -> d84d6da) (#672)

* Update jslib (f30d6f8 -> d84d6da)

* Updated imports/constructor to super

* OnlyOrg Policy (#669)

* added localization strings needed for the OnlyOrg policy

* added deprecation warning to policies page

* allowed OnlyOrg policy configuration

* blocked creating new orgs if already in an org with OnlyOrg enabled

* code review cleanup for onlyOrg

* removed a blank line

* code review cleanup for onlyOrg

* send listing actions

* updates

* access id

* update jslib

* re-work key and password derivation

* update jslib

* makeSendKey

* update access path

* store max access count

* update jslib

* l10n work

* l10n for access page

* l10n and cleanup

* fix l10n

Co-authored-by: Chad Scharf <3904944+cscharf@users.noreply.github.com>
Co-authored-by: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com>
Co-authored-by: Addison Beck <abeck@bitwarden.com>
This commit is contained in:
Kyle Spearrin 2020-11-04 14:49:08 -05:00 committed by GitHub
parent 1aa708aed4
commit e9273ff79a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 978 additions and 4 deletions

2
jslib

@ -1 +1 @@
Subproject commit 5e50aa1a195bde11fdc14e9bdf71542766fdbb8d Subproject commit 0e9e73ce95a321ee05edbb62c50f9e1828f69c5a

View File

@ -57,6 +57,9 @@ import {
import { VaultComponent as OrgVaultComponent } from './organizations/vault/vault.component'; import { VaultComponent as OrgVaultComponent } from './organizations/vault/vault.component';
import { AccessComponent } from './send/access.component';
import { SendComponent } from './send/send.component';
import { AccountComponent } from './settings/account.component'; import { AccountComponent } from './settings/account.component';
import { CreateOrganizationComponent } from './settings/create-organization.component'; import { CreateOrganizationComponent } from './settings/create-organization.component';
import { DomainRulesComponent } from './settings/domain-rules.component'; import { DomainRulesComponent } from './settings/domain-rules.component';
@ -141,6 +144,11 @@ const routes: Routes = [
canActivate: [UnauthGuardService], canActivate: [UnauthGuardService],
data: { titleId: 'deleteAccount' }, data: { titleId: 'deleteAccount' },
}, },
{
path: 'send/:sendId/:key',
component: AccessComponent,
data: { title: 'Bitwarden Send' },
},
], ],
}, },
{ {
@ -149,6 +157,7 @@ const routes: Routes = [
canActivate: [AuthGuardService], canActivate: [AuthGuardService],
children: [ children: [
{ path: 'vault', component: VaultComponent, data: { titleId: 'myVault' } }, { path: 'vault', component: VaultComponent, data: { titleId: 'myVault' } },
{ path: 'sends', component: SendComponent, data: { title: 'Send' } },
{ {
path: 'settings', path: 'settings',
component: SettingsComponent, component: SettingsComponent,

View File

@ -98,6 +98,10 @@ import { CollectionsComponent as OrgCollectionsComponent } from './organizations
import { GroupingsComponent as OrgGroupingsComponent } from './organizations/vault/groupings.component'; import { GroupingsComponent as OrgGroupingsComponent } from './organizations/vault/groupings.component';
import { VaultComponent as OrgVaultComponent } from './organizations/vault/vault.component'; import { VaultComponent as OrgVaultComponent } from './organizations/vault/vault.component';
import { AccessComponent } from './send/access.component';
import { AddEditComponent as SendAddEditComponent } from './send/add-edit.component';
import { SendComponent } from './send/send.component';
import { AccountComponent } from './settings/account.component'; import { AccountComponent } from './settings/account.component';
import { AddCreditComponent } from './settings/add-credit.component'; import { AddCreditComponent } from './settings/add-credit.component';
import { AdjustPaymentComponent } from './settings/adjust-payment.component'; import { AdjustPaymentComponent } from './settings/adjust-payment.component';
@ -179,7 +183,10 @@ import { I18nPipe } from 'jslib/angular/pipes/i18n.pipe';
import { SearchCiphersPipe } from 'jslib/angular/pipes/search-ciphers.pipe'; import { SearchCiphersPipe } from 'jslib/angular/pipes/search-ciphers.pipe';
import { SearchPipe } from 'jslib/angular/pipes/search.pipe'; import { SearchPipe } from 'jslib/angular/pipes/search.pipe';
import { registerLocaleData } from '@angular/common'; import {
registerLocaleData,
DatePipe,
} from '@angular/common';
import localeCa from '@angular/common/locales/ca'; import localeCa from '@angular/common/locales/ca';
import localeCs from '@angular/common/locales/cs'; import localeCs from '@angular/common/locales/cs';
import localeDa from '@angular/common/locales/da'; import localeDa from '@angular/common/locales/da';
@ -252,6 +259,7 @@ registerLocaleData(localeZhTw, 'zh-TW');
], ],
declarations: [ declarations: [
A11yTitleDirective, A11yTitleDirective,
AccessComponent,
AcceptOrganizationComponent, AcceptOrganizationComponent,
AccountComponent, AccountComponent,
SetPasswordComponent, SetPasswordComponent,
@ -358,6 +366,8 @@ registerLocaleData(localeZhTw, 'zh-TW');
SearchCiphersPipe, SearchCiphersPipe,
SearchPipe, SearchPipe,
SelectCopyDirective, SelectCopyDirective,
SendAddEditComponent,
SendComponent,
SettingsComponent, SettingsComponent,
ShareComponent, ShareComponent,
SsoComponent, SsoComponent,
@ -417,6 +427,7 @@ registerLocaleData(localeZhTw, 'zh-TW');
OrgUserGroupsComponent, OrgUserGroupsComponent,
PasswordGeneratorHistoryComponent, PasswordGeneratorHistoryComponent,
PurgeVaultComponent, PurgeVaultComponent,
SendAddEditComponent,
ShareComponent, ShareComponent,
TwoFactorAuthenticatorComponent, TwoFactorAuthenticatorComponent,
TwoFactorDuoComponent, TwoFactorDuoComponent,
@ -427,7 +438,7 @@ registerLocaleData(localeZhTw, 'zh-TW');
TwoFactorYubiKeyComponent, TwoFactorYubiKeyComponent,
UpdateKeyComponent, UpdateKeyComponent,
], ],
providers: [], providers: [DatePipe],
bootstrap: [AppComponent], bootstrap: [AppComponent],
}) })
export class AppModule { } export class AppModule { }

View File

@ -8,6 +8,9 @@
<li class="nav-item" routerLinkActive="active"> <li class="nav-item" routerLinkActive="active">
<a class="nav-link" routerLink="/vault">{{'myVault' | i18n}}</a> <a class="nav-link" routerLink="/vault">{{'myVault' | i18n}}</a>
</li> </li>
<li class="nav-item" routerLinkActive="active">
<a class="nav-link" routerLink="/sends">Send</a>
</li>
<li class="nav-item" routerLinkActive="active"> <li class="nav-item" routerLinkActive="active">
<a class="nav-link" routerLink="/tools">{{'tools' | i18n}}</a> <a class="nav-link" routerLink="/tools">{{'tools' | i18n}}</a>
</li> </li>

View File

@ -0,0 +1,59 @@
<form #form (ngSubmit)="load()" [appApiAction]="formPromise" class="container" ngNativeValidate>
<div class="row justify-content-md-center mt-5">
<div class="col-5">
<p class="lead text-center mb-4">Bitwarden Send</p>
<div class="card d-block">
<div class="card-body" *ngIf="loading" 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>
</div>
<div class="card-body" *ngIf="!loading && passwordRequired">
<p>{{'sendProtectedPassword' | i18n}}</p>
<p>{{'sendProtectedPasswordDontKnow' | i18n}}</p>
<div class="form-group">
<label for="password">{{'password' | i18n}}</label>
<input id="password" type="password" name="Password" class="text-monospace form-control"
[(ngModel)]="password" required appInputVerbatim appAutofocus>
</div>
<div class="d-flex">
<button type="submit" class="btn btn-primary btn-block btn-submit" [disabled]="form.loading">
<span>
<i class="fa fa-sign-in" aria-hidden="true"></i> {{'continue' | i18n}}
</span>
<i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="card-body" *ngIf="!loading && !passwordRequired && send">
<p class="text-center"><b>{{send.name}}</b></p>
<hr>
<!-- Text -->
<ng-container *ngIf="send.type === sendType.Text">
<app-callout *ngIf="send.text.hidden" type="tip">{{'sendHiddenByDefault' | i18n}}</app-callout>
<div class="form-group">
<textarea id="text" rows="8" name="Text" [(ngModel)]="sendText" class="form-control"
readonly (click)="selectText()"></textarea>
</div>
<button class="btn btn-block btn-link" type="button" (click)="toggleText()"
*ngIf="send.text.hidden">
<i class="fa fa-lg" aria-hidden="true"
[ngClass]="{'fa-eye': !showText, 'fa-eye-slash': showText}"></i>
{{'toggleVisibility' | i18n}}
</button>
<button class="btn btn-block btn-link" type="button" (click)="copyText()">
<i class="fa fa-copy" aria-hidden="true"></i> {{'copyValue' | i18n}}
</button>
</ng-container>
<!-- File -->
<ng-container *ngIf="send.type === sendType.File">
<p>{{send.file.fileName}}</p>
<button class="btn btn-primary btn-block" type="button" (click)="download()">
<i class="fa fa-download" aria-hidden="true"></i>
{{'downloadFile' | i18n}} ({{send.file.sizeName}})</button>
</ng-container>
</div>
</div>
</div>
</div>
</form>

View File

@ -0,0 +1,139 @@
import {
Component,
OnInit,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ApiService } from 'jslib/abstractions/api.service';
import { CryptoService } from 'jslib/abstractions/crypto.service';
import { CryptoFunctionService } from 'jslib/abstractions/cryptoFunction.service';
import { I18nService } from 'jslib/abstractions/i18n.service';
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
import { Utils } from 'jslib/misc/utils';
import { SymmetricCryptoKey } from 'jslib/models/domain/symmetricCryptoKey';
import { SendAccess } from 'jslib/models/domain/sendAccess';
import { SendAccessView } from 'jslib/models/view/sendAccessView';
import { SendType } from 'jslib/enums/sendType';
import { SendAccessRequest } from 'jslib/models/request/sendAccessRequest';
import { ErrorResponse } from 'jslib/models/response/errorResponse';
import { SendAccessResponse } from 'jslib/models/response/sendAccessResponse';
@Component({
selector: 'app-send-access',
templateUrl: 'access.component.html',
})
export class AccessComponent implements OnInit {
send: SendAccessView;
sendType = SendType;
downloading = false;
loading = true;
passwordRequired = false;
formPromise: Promise<SendAccessResponse>;
password: string;
showText = false;
private id: string;
private key: string;
private decKey: SymmetricCryptoKey;
constructor(private i18nService: I18nService, private cryptoFunctionService: CryptoFunctionService,
private apiService: ApiService, private platformUtilsService: PlatformUtilsService,
private route: ActivatedRoute, private cryptoService: CryptoService) {
}
get sendText() {
if (this.send == null || this.send.text == null) {
return null;
}
return this.showText ? this.send.text.text : this.send.text.maskedText;
}
ngOnInit() {
this.route.params.subscribe(async (params) => {
this.id = params.sendId;
this.key = params.key;
if (this.key == null || this.id == null) {
return;
}
await this.load();
});
}
async download() {
if (this.send == null || this.decKey == null) {
return;
}
if (this.downloading) {
return;
}
this.downloading = true;
const response = await fetch(new Request(this.send.file.url, { cache: 'no-store' }));
if (response.status !== 200) {
this.platformUtilsService.showToast('error', null, this.i18nService.t('errorOccurred'));
this.downloading = false;
return;
}
try {
const buf = await response.arrayBuffer();
const decBuf = await this.cryptoService.decryptFromBytes(buf, this.decKey);
this.platformUtilsService.saveFile(window, decBuf, null, this.send.file.fileName);
} catch (e) {
this.platformUtilsService.showToast('error', null, this.i18nService.t('errorOccurred'));
}
this.downloading = false;
}
selectText() {
(document.getElementById('text') as HTMLInputElement).select();
}
copyText() {
this.platformUtilsService.copyToClipboard(this.send.text.text);
this.platformUtilsService.showToast('success', null,
this.i18nService.t('valueCopied', this.i18nService.t('sendTypeText')));
}
toggleText() {
this.showText = !this.showText;
}
private async load() {
const keyArray = Utils.fromUrlB64ToArray(this.key);
const accessRequest = new SendAccessRequest();
if (this.password != null) {
const passwordHash = await this.cryptoFunctionService.pbkdf2(this.password, keyArray, 'sha256', 100000);
accessRequest.password = Utils.fromBufferToB64(passwordHash);
}
try {
let sendResponse: SendAccessResponse = null;
if (this.loading) {
sendResponse = await this.apiService.postSendAccess(this.id, accessRequest);
} else {
this.formPromise = this.apiService.postSendAccess(this.id, accessRequest);
sendResponse = await this.formPromise;
}
this.passwordRequired = false;
const sendAccess = new SendAccess(sendResponse);
this.decKey = await this.cryptoService.makeSendKey(keyArray);
this.send = await sendAccess.decrypt(this.decKey);
this.showText = this.send.text != null ? !this.send.text.hidden : true;
} catch (e) {
if (e instanceof ErrorResponse) {
if (e.statusCode === 401) {
this.passwordRequired = true;
}
}
}
this.loading = false;
}
}

View File

@ -0,0 +1,128 @@
<div class="modal fade" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="sendAddEditTitle">
<div class="modal-dialog modal-lg" role="document">
<form class="modal-content" #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate
autocomplete="off">
<div class="modal-header">
<h2 class="modal-title" id="sendAddEditTitle">{{title}}</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="send">
<div class="row" *ngIf="!editMode">
<div class="col-6 form-group">
<label for="type">{{'whatTypeOfSend' | i18n}}</label>
<select id="type" name="Type" [(ngModel)]="send.type" class="form-control" appAutofocus>
<option *ngFor="let o of typeOptions" [ngValue]="o.value">{{o.name}}</option>
</select>
</div>
</div>
<div class="row">
<div class="col-6 form-group">
<label for="name">{{'name' | i18n}}</label>
<input id="name" class="form-control" type="text" name="Name" [(ngModel)]="send.name" required>
</div>
</div>
<!-- Text -->
<ng-container *ngIf="send.type === sendType.Text">
<div class="form-group">
<label for="text">{{'sendTypeText' | i18n}}</label>
<textarea id="text" name="Text.Text" rows="6" [(ngModel)]="send.text.text"
class="form-control"></textarea>
</div>
<div class="form-group">
<div class="form-check">
<input class="form-check-input" type="checkbox" [(ngModel)]="send.text.hidden"
id="text-hidden" name="Text.Hidden">
<label class="form-check-label" for="text-hidden">{{'cfTypeHidden' | i18n}}</label>
</div>
</div>
</ng-container>
<!-- File -->
<ng-container *ngIf="send.type === sendType.File">
<div class="form-group">
<div *ngIf="editMode">
<strong class="d-block">{{'file' | i18n}}</strong>
{{send.file.fileName}} ({{send.file.sizeName}})
</div>
<div *ngIf="!editMode">
<label for="file">{{'file' | i18n}}</label>
<input type="file" id="file" class="form-control-file" name="file" required>
<small class="form-text text-muted">{{'maxFileSize' | i18n}}</small>
</div>
</div>
</ng-container>
<div class="row">
<div class="col-6 form-group">
<label for="deletionDate">{{'deletionDate' | i18n}}</label>
<input id="deletionDate" class="form-control" type="datetime-local" name="DeletionDate"
[(ngModel)]="deletionDate" required>
</div>
<div class="col-6 form-group">
<div class="d-flex">
<label for="expirationDate">{{'expirationDate' | i18n}}</label>
<a href="#" appStopClick (click)="clearExpiration()" class="ml-auto">
{{'clear' | i18n}}
</a>
</div>
<input id="expirationDate" class="form-control" type="datetime-local" name="ExpirationDate"
[(ngModel)]="expirationDate">
</div>
</div>
<div class="row">
<div class="col-6 form-group">
<label for="maxAccessCount">{{'maxAccessCount' | i18n}}</label>
<input id="maxAccessCount" class="form-control" type="number" name="MaxAccessCount"
[(ngModel)]="send.maxAccessCount">
</div>
<div class="col-6 form-group" *ngIf="editMode">
<label for="accessCount">{{'currentAccessCount' | i18n}}</label>
<input id="accessCount" class="form-control" type="number" name="AccessCount" readonly
[(ngModel)]="send.accessCount">
</div>
</div>
<div class="row">
<div class="col-6 form-group">
<label for="password" *ngIf="!hasPassword">{{'password' | i18n}}</label>
<label for="password" *ngIf="hasPassword">{{'newPassword' | i18n}}</label>
<input id="password" class="form-control" type="password" name="Password"
[(ngModel)]="password">
</div>
</div>
<div class="form-group">
<label for="notes">{{'notes' | i18n}}</label>
<textarea id="notes" name="Notes" rows="6" [(ngModel)]="send.notes" class="form-control"></textarea>
</div>
<div class="form-group">
<div class="form-check">
<input class="form-check-input" type="checkbox" [(ngModel)]="send.disabled" id="disabled"
name="Disabled">
<label class="form-check-label" for="disabled">{{'disabled' | i18n}}</label>
</div>
</div>
<div class="form-group" *ngIf="link">
<label for="link">{{'sendLink' | i18n}}</label>
<input type="text" readonly id="link" name="Link" [(ngModel)]="link" class="form-control">
</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 class="ml-auto" *ngIf="send">
<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,276 @@
import { DatePipe } from '@angular/common';
import {
EventEmitter,
Input,
Output,
} from '@angular/core';
import { Component } from '@angular/core';
import { SendType } from 'jslib/enums/sendType';
import { ApiService } from 'jslib/abstractions/api.service';
import { CryptoService } from 'jslib/abstractions/crypto.service';
import { CryptoFunctionService } from 'jslib/abstractions/cryptoFunction.service';
import { EnvironmentService } from 'jslib/abstractions/environment.service';
import { I18nService } from 'jslib/abstractions/i18n.service';
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
import { SendView } from 'jslib/models/view/sendView';
import { SendFileView } from 'jslib/models/view/sendFileView';
import { SendTextView } from 'jslib/models/view/sendTextView';
import { Send } from 'jslib/models/domain/send';
import { SendFile } from 'jslib/models/domain/sendFile';
import { SendText } from 'jslib/models/domain/sendText';
import { SendData } from 'jslib/models/data/sendData';
import { SendRequest } from 'jslib/models/request/sendRequest';
import { Utils } from 'jslib/misc/utils';
@Component({
selector: 'app-send-add-edit',
templateUrl: 'add-edit.component.html',
})
export class AddEditComponent {
@Input() sendId: string;
@Input() type: SendType;
@Output() onSavedSend = new EventEmitter<SendView>();
@Output() onDeletedSend = new EventEmitter<SendView>();
@Output() onCancelled = new EventEmitter<SendView>();
editMode: boolean = false;
send: SendView;
link: string;
title: string;
deletionDate: string;
expirationDate: string;
hasPassword: boolean;
password: string;
formPromise: Promise<any>;
deletePromise: Promise<any>;
sendType = SendType;
typeOptions: any[];
constructor(private i18nService: I18nService, private platformUtilsService: PlatformUtilsService,
private apiService: ApiService, private cryptoService: CryptoService,
private cryptoFunctionService: CryptoFunctionService, private environmentService: EnvironmentService,
private datePipe: DatePipe) {
this.typeOptions = [
{ name: i18nService.t('sendTypeFile'), value: SendType.File },
{ name: i18nService.t('sendTypeText'), value: SendType.Text },
];
}
async ngOnInit() {
await this.load();
}
async load() {
this.editMode = this.sendId != null;
if (this.editMode) {
this.editMode = true;
this.title = this.i18nService.t('editSend');
} else {
this.title = this.i18nService.t('createSend');
}
if (this.send == null) {
if (this.editMode) {
const send = await this.loadSend();
this.send = await send.decrypt();
} else {
this.send = new SendView();
this.send.type = this.type == null ? SendType.File : this.type;
this.send.file = new SendFileView();
this.send.text = new SendTextView();
this.send.deletionDate = new Date();
this.send.deletionDate.setDate(this.send.deletionDate.getDate() + 7);
}
}
this.hasPassword = this.send.password != null && this.send.password.trim() !== '';
// Parse dates
this.deletionDate = this.send.deletionDate == null ? null :
this.datePipe.transform(this.send.deletionDate, 'yyyy-MM-ddTHH:mm');
this.expirationDate = this.send.expirationDate == null ? null :
this.datePipe.transform(this.send.expirationDate, 'yyyy-MM-ddTHH:mm');
if (this.editMode) {
let webVaultUrl = this.environmentService.getWebVaultUrl();
if (webVaultUrl == null) {
webVaultUrl = 'https://vault.bitwarden.com';
}
this.link = webVaultUrl + '/#/send/' + this.send.accessId + '/' + this.send.urlB64Key;
}
}
async submit(): Promise<boolean> {
if (this.send.name == null || this.send.name === '') {
this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'),
this.i18nService.t('nameRequired'));
return false;
}
let file: File = null;
if (this.send.type === SendType.File && !this.editMode) {
const fileEl = document.getElementById('file') as HTMLInputElement;
const files = fileEl.files;
if (files == null || files.length === 0) {
this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'),
this.i18nService.t('selectFile'));
return;
}
file = files[0];
if (file.size > 104857600) { // 100 MB
this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'),
this.i18nService.t('maxFileSize'));
return;
}
}
const encSend = await this.encryptSend(file);
try {
this.formPromise = this.saveSend(encSend);
await this.formPromise;
this.send.id = encSend[0].id;
this.platformUtilsService.showToast('success', null,
this.i18nService.t(this.editMode ? 'editedSend' : 'createdSend'));
this.onSavedSend.emit(this.send);
return true;
} catch { }
return false;
}
clearExpiration() {
this.expirationDate = null;
}
async delete(): Promise<void> {
if (this.deletePromise != null) {
return;
}
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t('deleteSendConfirmation'),
this.i18nService.t('deleteSend'),
this.i18nService.t('yes'), this.i18nService.t('no'), 'warning');
if (!confirmed) {
return;
}
try {
this.deletePromise = this.apiService.deleteSend(this.send.id);
await this.deletePromise;
this.platformUtilsService.showToast('success', null, this.i18nService.t('deletedSend'));
await this.load();
this.onDeletedSend.emit(this.send);
} catch { }
}
protected async loadSend(): Promise<Send> {
const response = await this.apiService.getSend(this.sendId);
const data = new SendData(response);
return new Send(data);
}
protected async encryptSend(file: File): Promise<[Send, ArrayBuffer]> {
let fileData: ArrayBuffer = null;
const send = new Send();
send.id = this.send.id;
send.type = this.send.type;
send.disabled = this.send.disabled;
send.maxAccessCount = this.send.maxAccessCount;
if (this.send.key == null) {
this.send.key = await this.cryptoFunctionService.randomBytes(16);
this.send.cryptoKey = await this.cryptoService.makeSendKey(this.send.key);
}
if (this.password != null) {
const passwordHash = await this.cryptoFunctionService.pbkdf2(this.password,
this.send.key, 'sha256', 100000);
send.password = Utils.fromBufferToB64(passwordHash);
}
send.key = await this.cryptoService.encrypt(this.send.key, null);
send.name = await this.cryptoService.encrypt(this.send.name, this.send.cryptoKey);
send.notes = await this.cryptoService.encrypt(this.send.notes, this.send.cryptoKey);
if (send.type === SendType.Text) {
send.text = new SendText();
send.text.text = await this.cryptoService.encrypt(this.send.text.text, this.send.cryptoKey);
send.text.hidden = this.send.text.hidden;
} else if (send.type === SendType.File) {
send.file = new SendFile();
if (file != null) {
fileData = await this.parseFile(send, file);
}
}
// Parse dates
try {
send.deletionDate = this.deletionDate == null ? null : new Date(this.deletionDate);
} catch {
send.deletionDate = null;
}
try {
send.expirationDate = this.expirationDate == null ? null : new Date(this.expirationDate);
} catch {
send.expirationDate = null;
}
return [send, fileData];
}
protected async saveSend(sendData: [Send, ArrayBuffer]) {
const request = new SendRequest(sendData[0]);
if (sendData[0].id == null) {
if (sendData[0].type === SendType.Text) {
await this.apiService.postSend(request);
} else {
const fd = new FormData();
try {
const blob = new Blob([sendData[1]], { type: 'application/octet-stream' });
fd.append('model', JSON.stringify(request));
fd.append('data', blob, sendData[0].file.fileName.encryptedString);
} catch (e) {
if (Utils.isNode && !Utils.isBrowser) {
fd.append('model', JSON.stringify(request));
fd.append('data', Buffer.from(sendData[1]) as any, {
filepath: sendData[0].file.fileName.encryptedString,
contentType: 'application/octet-stream',
} as any);
} else {
throw e;
}
}
await this.apiService.postSendFile(fd);
}
} else {
await this.apiService.putSend(sendData[0].id, request);
}
}
private parseFile(send: Send, file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.onload = async (evt) => {
try {
send.file.fileName = await this.cryptoService.encrypt(file.name, this.send.cryptoKey);
const fileData = await this.cryptoService.encryptToBytes(evt.target.result as ArrayBuffer,
this.send.cryptoKey);
resolve(fileData);
} catch (e) {
reject(e);
}
};
reader.onerror = (evt) => {
reject('Error reading file.');
};
});
}
}

View File

@ -0,0 +1,102 @@
<div class="container page-content">
<div class="row">
<div class="col-3 groupings">
<div class="card vault-filters">
<div class="card-header d-flex">
{{'filters' | i18n}}
</div>
<div class="card-body">
<input type="search" placeholder="{{searchPlaceholder || ('searchSends' | i18n)}}" id="search"
class="form-control" [(ngModel)]="searchText" (input)="searchTextChanged()" autocomplete="off"
appAutofocus>
<ul class="fa-ul card-ul">
<li [ngClass]="{active: selectedAll}">
<a href="#" appStopClick (click)="selectAll()">
<i class="fa-li fa fa-fw fa-th"></i>{{'allSends' | i18n}}
</a>
</li>
</ul>
<h3>{{'types' | i18n}}</h3>
<ul class="fa-ul card-ul">
<li [ngClass]="{active: selectedType === sendType.Text}">
<a href="#" appStopClick (click)="selectType(sendType.Text)">
<i class="fa-li fa fa-fw fa-file-text-o"></i>{{'sendTypeText' | i18n}}
</a>
</li>
<li [ngClass]="{active: selectedType === sendType.File}">
<a href="#" appStopClick (click)="selectType(sendType.File)">
<i class="fa-li fa fa-fw fa-file-o"></i>{{'sendTypeFile' | i18n}}
</a>
</li>
</ul>
</div>
</div>
</div>
<div class="col-9">
<div class="page-header d-flex">
<h1>
Send
<small #actionSpinner [appApiAction]="actionPromise">
<ng-container *ngIf="actionSpinner.loading">
<i class="fa fa-spinner fa-spin text-muted" title="{{'loading' | i18n}}"
aria-hidden="true"></i>
<span class="sr-only">{{'loading' | i18n}}</span>
</ng-container>
</small>
</h1>
<div class="ml-auto d-flex">
<button type="button" class="btn btn-outline-primary btn-sm" (click)="addSend()">
<i class="fa fa-plus fa-fw" aria-hidden="true"></i>{{'createSend' | i18n}}
</button>
</div>
</div>
<!--Listing Table-->
<table class="table table-hover table-list" *ngIf="sends && sends.length">
<tbody>
<tr *ngFor="let s of sends">
<td class="table-list-icon">
<div class="icon" aria-hidden="true">
<i class="fa fa-fw fa-lg fa-file-o" *ngIf="s.type == sendType.File"></i>
<i class="fa fa-fw fa-lg fa-file-text-o" *ngIf="s.type == sendType.Text"></i>
</div>
</td>
<td class="reduced-lh wrap">
<a href="#" appStopClick appStopProp (click)="editSend(s)">{{s.name}}</a>
<ng-container *ngIf="s.password">
<i class="fa fa-key" appStopProp title="{{'password' | i18n}}" aria-hidden="true"></i>
<span class="sr-only">{{'password' | i18n}}</span>
</ng-container>
<br>
<small appStopProp>{{s.deletionDate | date:'medium'}}</small>
</td>
<td class="table-list-options">
<div class="dropdown" appListDropdown>
<button class="btn btn-outline-secondary dropdown-toggle" type="button"
id="dropdownMenuButton" 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" aria-labelledby="dropdownMenuButton">
<a class="dropdown-item" href="#" appStopClick (click)="copy(s)">
<i class="fa fa-fw fa-copy" aria-hidden="true"></i>
{{'copySendLink' | i18n}}
</a>
<a class="dropdown-item" href="#" appStopClick (click)="removePassword(s)"
*ngIf="s.password">
<i class="fa fa-fw fa-undo" aria-hidden="true"></i>
{{'removePassword' | i18n}}
</a>
<a class="dropdown-item text-danger" href="#" appStopClick (click)="delete(s)">
<i class="fa fa-fw fa-trash-o" aria-hidden="true"></i>
{{'delete' | i18n}}
</a>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<ng-template #sendAddEdit></ng-template>

View File

@ -0,0 +1,152 @@
import {
Component,
ComponentFactoryResolver,
OnInit,
ViewChild,
ViewContainerRef,
} from '@angular/core';
import { SendType } from 'jslib/enums/sendType';
import { SendView } from 'jslib/models/view/sendView';
import { AddEditComponent } from './add-edit.component';
import { ModalComponent } from '../modal.component';
import { ApiService } from 'jslib/abstractions/api.service';
import { EnvironmentService } from 'jslib/abstractions/environment.service';
import { I18nService } from 'jslib/abstractions/i18n.service';
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
import { UserService } from 'jslib/abstractions/user.service';
import { SendData } from 'jslib/models/data/sendData';
import { Send } from 'jslib/models/domain/send';
@Component({
selector: 'app-send',
templateUrl: 'send.component.html',
})
export class SendComponent implements OnInit {
@ViewChild('sendAddEdit', { read: ViewContainerRef, static: true }) sendAddEditModalRef: ViewContainerRef;
sendType = SendType;
loading = true;
expired: boolean = false;
type: SendType = null;
sends: SendView[] = [];
modal: ModalComponent = null;
actionPromise: any;
constructor(private apiService: ApiService, private userService: UserService,
private i18nService: I18nService, private componentFactoryResolver: ComponentFactoryResolver,
private platformUtilsService: PlatformUtilsService, private environmentService: EnvironmentService) { }
async ngOnInit() {
await this.load();
}
async load() {
this.loading = true;
const userId = await this.userService.getUserId();
const sends = await this.apiService.getSends();
const sendsArr: SendView[] = [];
if (sends != null && sends.data != null) {
for (const res of sends.data) {
const data = new SendData(res, userId);
const send = new Send(data);
const view = await send.decrypt();
sendsArr.push(view);
}
}
this.sends = sendsArr;
this.loading = false;
}
addSend() {
const component = this.editSend(null);
component.type = this.type;
}
editSend(send: SendView) {
if (this.modal != null) {
this.modal.close();
}
const factory = this.componentFactoryResolver.resolveComponentFactory(ModalComponent);
this.modal = this.sendAddEditModalRef.createComponent(factory).instance;
const childComponent = this.modal.show<AddEditComponent>(
AddEditComponent, this.sendAddEditModalRef);
childComponent.sendId = send == null ? null : send.id;
childComponent.onSavedSend.subscribe(async (s: SendView) => {
this.modal.close();
await this.load();
});
childComponent.onDeletedSend.subscribe(async (s: SendView) => {
this.modal.close();
await this.load();
});
this.modal.onClosed.subscribe(() => {
this.modal = null;
});
return childComponent;
}
async removePassword(s: SendView): Promise<boolean> {
if (this.actionPromise != null || s.password == null) {
return;
}
const confirmed = await this.platformUtilsService.showDialog(this.i18nService.t('removePasswordConfirmation'),
this.i18nService.t('removePassword'),
this.i18nService.t('yes'), this.i18nService.t('no'), 'warning');
if (!confirmed) {
return false;
}
try {
this.actionPromise = this.apiService.putSendRemovePassword(s.id);
await this.actionPromise;
this.platformUtilsService.showToast('success', null, this.i18nService.t('removedPassword'));
await this.load();
} catch { }
this.actionPromise = null;
}
async delete(s: SendView): Promise<boolean> {
if (this.actionPromise != null) {
return false;
}
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t('deleteSendConfirmation'),
this.i18nService.t('deleteSend'),
this.i18nService.t('yes'), this.i18nService.t('no'), 'warning');
if (!confirmed) {
return false;
}
try {
this.actionPromise = this.apiService.deleteSend(s.id);
await this.actionPromise;
this.platformUtilsService.showToast('success', null, this.i18nService.t('deletedSend'));
await this.load();
} catch { }
this.actionPromise = null;
return true;
}
copy(s: SendView) {
let webVaultUrl = this.environmentService.getWebVaultUrl();
if (webVaultUrl == null) {
webVaultUrl = 'https://vault.bitwarden.com';
}
const link = webVaultUrl + '/#/send/' + s.accessId + '/' + s.urlB64Key;
this.platformUtilsService.copyToClipboard(link);
this.platformUtilsService.showToast('success', null,
this.i18nService.t('valueCopied', this.i18nService.t('sendLink')));
}
}

View File

@ -37,6 +37,9 @@
"password": { "password": {
"message": "Password" "message": "Password"
}, },
"newPassword": {
"message": "New Password"
},
"passphrase": { "passphrase": {
"message": "Passphrase" "message": "Passphrase"
}, },
@ -3238,5 +3241,97 @@
}, },
"requireSsoPolicyReqError": { "requireSsoPolicyReqError": {
"message": "Single Organization policy not enabled." "message": "Single Organization policy not enabled."
},
"sendTypeFile": {
"message": "File"
},
"sendTypeText": {
"message": "Text"
},
"createSend": {
"message": "Create New Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"editSend": {
"message": "Edit Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"createdSend": {
"message": "Created Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"editedSend": {
"message": "Edited Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"deletedSend": {
"message": "Deleted Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"deleteSend": {
"message": "Delete Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"deleteSendConfirmation": {
"message": "Are you sure you want to delete this Send?",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"whatTypeOfSend": {
"message": "What type of Send is this?",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"deletionDate": {
"message": "Deletion Date"
},
"expirationDate": {
"message": "Expiration Date"
},
"maxAccessCount": {
"message": "Maximum Access Count"
},
"currentAccessCount": {
"message": "Current Access Count"
},
"disabled": {
"message": "Disabled"
},
"sendLink": {
"message": "Send Link",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"copySendLink": {
"message": "Copy Send Link",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"removePassword": {
"message": "Remove Password"
},
"removedPassword": {
"message": "Removed Password"
},
"removePasswordConfirmation": {
"message": "Are you sure you want to remove the password?"
},
"allSends": {
"message": "All Sends"
},
"searchSends": {
"message": "Search Sends",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"sendProtectedPassword": {
"message": "This Send is protected with a password. Please type the password below to continue.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"sendProtectedPasswordDontKnow": {
"message": "Don't know the password? Ask the Sender for the password needed to access this Send.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"sendHiddenByDefault": {
"message": "This send is hidden by default. You can toggle its visibility using the button below.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"downloadFile": {
"message": "Download File"
} }
} }

View File

@ -462,7 +462,7 @@ input[type="search"]::-webkit-search-cancel-button {
color: #c40800; color: #c40800;
} }
app-vault-groupings, app-org-vault-groupings { app-vault-groupings, app-org-vault-groupings, .groupings {
.card { .card {
#search { #search {
margin-bottom: 1rem; margin-bottom: 1rem;