removed backup step in wallet creation
This commit is contained in:
parent
0853632827
commit
d5b04d7ad8
5 changed files with 6 additions and 256 deletions
|
|
@ -1,35 +1,9 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
angular.module('copayApp.controllers').controller('CopayersController',
|
angular.module('copayApp.controllers').controller('CopayersController',
|
||||||
function($scope, $rootScope, $location, backupService, controllerUtils) {
|
function($scope, $rootScope, $location, controllerUtils) {
|
||||||
$scope.isSafari = Object.prototype.toString.call(window.HTMLElement).indexOf('Constructor') > 0;
|
|
||||||
$scope.hideAdv = true;
|
|
||||||
$rootScope.title = 'Copayers';
|
$rootScope.title = 'Copayers';
|
||||||
|
|
||||||
$scope.skipBackup = function() {
|
|
||||||
var w = $rootScope.wallet;
|
|
||||||
w.setBackupReady(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.backup = function() {
|
|
||||||
var w = $rootScope.wallet;
|
|
||||||
if ($scope.isSafari) {
|
|
||||||
$scope.viewBackup(w);
|
|
||||||
} else {
|
|
||||||
w.setBackupReady();
|
|
||||||
$scope.downloadBackup(w);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.downloadBackup = function(w) {
|
|
||||||
backupService.walletDownload(w);
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.viewBackup = function(w) {
|
|
||||||
$scope.backupPlainText = backupService.walletEncrypted(w);
|
|
||||||
$scope.hideViewBackup = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.goToWallet = function() {
|
$scope.goToWallet = function() {
|
||||||
controllerUtils.updateAddressList();
|
controllerUtils.updateAddressList();
|
||||||
$location.path('/receive');
|
$location.path('/receive');
|
||||||
|
|
@ -41,15 +15,4 @@ angular.module('copayApp.controllers').controller('CopayersController',
|
||||||
}
|
}
|
||||||
return $scope.copayers;
|
return $scope.copayers;
|
||||||
}
|
}
|
||||||
|
|
||||||
$scope.isBackupReady = function(copayer) {
|
|
||||||
if ($rootScope.wallet) {
|
|
||||||
return $rootScope.wallet.publicKeyRing.isBackupReady(copayer.copayerId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope.deleteWallet = function() {
|
|
||||||
controllerUtils.deleteWallet($scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ var HDParams = require('./HDParams');
|
||||||
* @param {Object[]} [opts.indexes] - an array to be deserialized using {@link HDParams#fromList}
|
* @param {Object[]} [opts.indexes] - an array to be deserialized using {@link HDParams#fromList}
|
||||||
* (defaults to all indexes in zero)
|
* (defaults to all indexes in zero)
|
||||||
* @param {Object=} opts.nicknameFor - nicknames for other copayers
|
* @param {Object=} opts.nicknameFor - nicknames for other copayers
|
||||||
* @param {boolean[]} [opts.copayersBackup] - whether other copayers have backed up their wallets
|
|
||||||
*/
|
*/
|
||||||
function PublicKeyRing(opts) {
|
function PublicKeyRing(opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
|
|
@ -43,7 +42,6 @@ function PublicKeyRing(opts) {
|
||||||
this.publicKeysCache = {};
|
this.publicKeysCache = {};
|
||||||
this.nicknameFor = opts.nicknameFor || {};
|
this.nicknameFor = opts.nicknameFor || {};
|
||||||
this.copayerIds = [];
|
this.copayerIds = [];
|
||||||
this.copayersBackup = opts.copayersBackup || [];
|
|
||||||
this.addressToPath = {};
|
this.addressToPath = {};
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
@ -63,7 +61,6 @@ function PublicKeyRing(opts) {
|
||||||
* @param {Object[]} data.indexes - an array of objects that can be turned into
|
* @param {Object[]} data.indexes - an array of objects that can be turned into
|
||||||
* an array of HDParams
|
* an array of HDParams
|
||||||
* @param {Object} data.nicknameFor - a registry of nicknames for other copayers
|
* @param {Object} data.nicknameFor - a registry of nicknames for other copayers
|
||||||
* @param {boolean[]} data.copayersBackup - whether copayers have backed up their wallets
|
|
||||||
* @param {string[]} data.copayersExtPubKeys - the extended public keys of copayers
|
* @param {string[]} data.copayersExtPubKeys - the extended public keys of copayers
|
||||||
* @returns {Object} a trimmed down version of PublicKeyRing that can be used
|
* @returns {Object} a trimmed down version of PublicKeyRing that can be used
|
||||||
* as a parameter
|
* as a parameter
|
||||||
|
|
@ -71,7 +68,7 @@ function PublicKeyRing(opts) {
|
||||||
PublicKeyRing.trim = function(data) {
|
PublicKeyRing.trim = function(data) {
|
||||||
var opts = {};
|
var opts = {};
|
||||||
['walletId', 'networkName', 'requiredCopayers', 'totalCopayers',
|
['walletId', 'networkName', 'requiredCopayers', 'totalCopayers',
|
||||||
'indexes', 'nicknameFor', 'copayersBackup', 'copayersExtPubKeys'
|
'indexes', 'nicknameFor', 'copayersExtPubKeys'
|
||||||
].forEach(function(k) {
|
].forEach(function(k) {
|
||||||
opts[k] = data[k];
|
opts[k] = data[k];
|
||||||
});
|
});
|
||||||
|
|
@ -119,7 +116,6 @@ PublicKeyRing.prototype.toObj = function() {
|
||||||
requiredCopayers: this.requiredCopayers,
|
requiredCopayers: this.requiredCopayers,
|
||||||
totalCopayers: this.totalCopayers,
|
totalCopayers: this.totalCopayers,
|
||||||
indexes: HDParams.serialize(this.indexes),
|
indexes: HDParams.serialize(this.indexes),
|
||||||
copayersBackup: this.copayersBackup,
|
|
||||||
|
|
||||||
copayersExtPubKeys: this.copayersHK.map(function(b) {
|
copayersExtPubKeys: this.copayersHK.map(function(b) {
|
||||||
return b.extendedPublicKeyString();
|
return b.extendedPublicKeyString();
|
||||||
|
|
@ -685,48 +681,6 @@ PublicKeyRing.prototype._mergePubkeys = function(inPKR) {
|
||||||
return hasChanged;
|
return hasChanged;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* @desc
|
|
||||||
* Mark backup as done for us
|
|
||||||
*
|
|
||||||
* @TODO: REVIEW FUNCTIONALITY - it used to have a parameter that was not used at all!
|
|
||||||
*
|
|
||||||
* @return {boolean} true if everybody has backed up their wallet
|
|
||||||
*/
|
|
||||||
PublicKeyRing.prototype.setBackupReady = function() {
|
|
||||||
if (this.isBackupReady()) return false;
|
|
||||||
|
|
||||||
var cid = this.myCopayerId();
|
|
||||||
this.copayersBackup.push(cid);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @desc returns true if a copayer has backed up his wallet
|
|
||||||
* @param {string=} copayerId - the pubkey of a copayer, defaults to our own's
|
|
||||||
* @return {boolean} if this copayer has backed up
|
|
||||||
*/
|
|
||||||
PublicKeyRing.prototype.isBackupReady = function(copayerId) {
|
|
||||||
var cid = copayerId || this.myCopayerId();
|
|
||||||
return this.copayersBackup.indexOf(cid) != -1;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @desc returns true if all copayers have backed up their wallets
|
|
||||||
* @return {boolean}
|
|
||||||
*/
|
|
||||||
PublicKeyRing.prototype.isFullyBackup = function() {
|
|
||||||
return this.remainingBackups() == 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @desc returns the amount of backups remaining
|
|
||||||
* @return {boolean}
|
|
||||||
*/
|
|
||||||
PublicKeyRing.prototype.remainingBackups = function() {
|
|
||||||
return this.totalCopayers - this.copayersBackup.length;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @desc
|
* @desc
|
||||||
* Merges this public key ring with another one, optionally ignoring the
|
* Merges this public key ring with another one, optionally ignoring the
|
||||||
|
|
@ -742,7 +696,6 @@ PublicKeyRing.prototype.merge = function(inPKR, ignoreId) {
|
||||||
var hasChanged = false;
|
var hasChanged = false;
|
||||||
hasChanged |= this.mergeIndexes(inPKR.indexes);
|
hasChanged |= this.mergeIndexes(inPKR.indexes);
|
||||||
hasChanged |= this._mergePubkeys(inPKR);
|
hasChanged |= this._mergePubkeys(inPKR);
|
||||||
hasChanged |= this._mergeBackups(inPKR.copayersBackup);
|
|
||||||
|
|
||||||
return !!hasChanged;
|
return !!hasChanged;
|
||||||
};
|
};
|
||||||
|
|
@ -768,22 +721,5 @@ PublicKeyRing.prototype.mergeIndexes = function(indexes) {
|
||||||
return !!hasChanged
|
return !!hasChanged
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @desc merges information about backups done by another copy of PublicKeyRing
|
|
||||||
* @param {string[]} backups - another copy of backups
|
|
||||||
* @return {boolean} true if the internal state has changed
|
|
||||||
*/
|
|
||||||
PublicKeyRing.prototype._mergeBackups = function(backups) {
|
|
||||||
var self = this;
|
|
||||||
var hasChanged = false;
|
|
||||||
|
|
||||||
backups.forEach(function(cid) {
|
|
||||||
var isNew = self.copayersBackup.indexOf(cid) == -1;
|
|
||||||
if (isNew) self.copayersBackup.push(cid);
|
|
||||||
hasChanged |= isNew;
|
|
||||||
});
|
|
||||||
|
|
||||||
return !!hasChanged
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = PublicKeyRing;
|
module.exports = PublicKeyRing;
|
||||||
|
|
|
||||||
|
|
@ -94,13 +94,6 @@ function Wallet(opts) {
|
||||||
this.lastTimestamp = opts.lastTimestamp || 0;
|
this.lastTimestamp = opts.lastTimestamp || 0;
|
||||||
this.lastMessageFrom = {};
|
this.lastMessageFrom = {};
|
||||||
|
|
||||||
//to avoid confirmation of copayer's backups if is imported from a file
|
|
||||||
this.isImported = opts.isImported || false;
|
|
||||||
|
|
||||||
|
|
||||||
//to avoid waiting others copayers to make a backup and login immediatly
|
|
||||||
this.forcedLogin = opts.forcedLogin || false;
|
|
||||||
|
|
||||||
this.paymentRequests = opts.paymentRequests || {};
|
this.paymentRequests = opts.paymentRequests || {};
|
||||||
|
|
||||||
var networkName = Wallet.obtainNetworkName(opts);
|
var networkName = Wallet.obtainNetworkName(opts);
|
||||||
|
|
@ -153,7 +146,6 @@ Wallet.PERSISTED_PROPERTIES = [
|
||||||
'txProposals',
|
'txProposals',
|
||||||
'privateKey',
|
'privateKey',
|
||||||
'addressBook',
|
'addressBook',
|
||||||
'backupOffered',
|
|
||||||
'lastTimestamp',
|
'lastTimestamp',
|
||||||
'secretNumber',
|
'secretNumber',
|
||||||
];
|
];
|
||||||
|
|
@ -1000,7 +992,6 @@ Wallet.fromUntrustedObj = function(obj, readOpts) {
|
||||||
*
|
*
|
||||||
* @param readOpts.network
|
* @param readOpts.network
|
||||||
* @param readOpts.blockchain
|
* @param readOpts.blockchain
|
||||||
* @param readOpts.isImported {boolean} - tag wallet as 'imported' (skip forced backup step)
|
|
||||||
* @param readOpts.{string[]} skipFields - parameters to ignore when importing
|
* @param readOpts.{string[]} skipFields - parameters to ignore when importing
|
||||||
*/
|
*/
|
||||||
Wallet.fromObj = function(o, readOpts) {
|
Wallet.fromObj = function(o, readOpts) {
|
||||||
|
|
@ -1071,7 +1062,6 @@ Wallet.fromObj = function(o, readOpts) {
|
||||||
|
|
||||||
opts.blockchainOpts = readOpts.blockchainOpts;
|
opts.blockchainOpts = readOpts.blockchainOpts;
|
||||||
opts.networkOpts = readOpts.networkOpts;
|
opts.networkOpts = readOpts.networkOpts;
|
||||||
opts.isImported = readOpts.isImported || false;
|
|
||||||
|
|
||||||
return new Wallet(opts);
|
return new Wallet(opts);
|
||||||
};
|
};
|
||||||
|
|
@ -2617,24 +2607,11 @@ Wallet.prototype.requiresMultipleSignatures = function() {
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @desc Returns true if the keyring is complete and all users have backed up the wallet
|
* @desc Returns true if the keyring is complete
|
||||||
* @return {boolean}
|
* @return {boolean}
|
||||||
*/
|
*/
|
||||||
Wallet.prototype.isReady = function() {
|
Wallet.prototype.isReady = function() {
|
||||||
var ret = this.publicKeyRing.isComplete() && (this.publicKeyRing.isFullyBackup() || this.isImported || this.forcedLogin);
|
return this.publicKeyRing.isComplete();
|
||||||
return ret;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @desc Mark that our backup is ready and send a sync to other users.
|
|
||||||
*
|
|
||||||
* Also backs up the wallet
|
|
||||||
*/
|
|
||||||
Wallet.prototype.setBackupReady = function(forcedLogin) {
|
|
||||||
this.forcedLogin = forcedLogin;
|
|
||||||
this.publicKeyRing.setBackupReady();
|
|
||||||
this.sendPublicKeyRing();
|
|
||||||
this.emitAndKeepAlive('txProposalsUpdated');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -191,58 +191,6 @@ describe('PublicKeyRing model', function() {
|
||||||
w.getHDParams(k.pub).getReceiveIndex().should.equal(2);
|
w.getHDParams(k.pub).getReceiveIndex().should.equal(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set backup ready', function() {
|
|
||||||
var w = getCachedW().w;
|
|
||||||
w.isBackupReady().should.equal(false);
|
|
||||||
w.setBackupReady();
|
|
||||||
w.isBackupReady().should.equal(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
it('should check for other backups', function() {
|
|
||||||
var w = createW().w;
|
|
||||||
w.remainingBackups().should.equal(5);
|
|
||||||
w.isFullyBackup().should.equal(false);
|
|
||||||
|
|
||||||
w.setBackupReady();
|
|
||||||
w.remainingBackups().should.equal(4);
|
|
||||||
w.isFullyBackup().should.equal(false);
|
|
||||||
|
|
||||||
w.copayersBackup = ["a", "b", "c", "d", "e"];
|
|
||||||
w.remainingBackups().should.equal(0);
|
|
||||||
w.isFullyBackup().should.equal(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should merge backup', function() {
|
|
||||||
var w = getCachedW().w;
|
|
||||||
var hasChanged;
|
|
||||||
|
|
||||||
w.copayersBackup = ["a", "b"];
|
|
||||||
hasChanged = w._mergeBackups(["b", "c"]);
|
|
||||||
w.copayersBackup.length.should.equal(3);
|
|
||||||
hasChanged.should.equal(true);
|
|
||||||
|
|
||||||
w.copayersBackup = ["a", "b", "c"];
|
|
||||||
hasChanged = w._mergeBackups(["b", "c"]);
|
|
||||||
w.copayersBackup.length.should.equal(3);
|
|
||||||
hasChanged.should.equal(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should merge backup tests', function() {
|
|
||||||
var w = createW().w;
|
|
||||||
|
|
||||||
var w2 = new PublicKeyRing({
|
|
||||||
networkName: 'livenet',
|
|
||||||
walletId: w.walletId,
|
|
||||||
});
|
|
||||||
w.merge(w2).should.equal(false);
|
|
||||||
w.remainingBackups().should.equal(5);
|
|
||||||
|
|
||||||
w2.setBackupReady();
|
|
||||||
w.merge(w2).should.equal(true);
|
|
||||||
w.remainingBackups().should.equal(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#merge index tests', function() {
|
it('#merge index tests', function() {
|
||||||
var k = createW();
|
var k = createW();
|
||||||
var w = k.w;
|
var w = k.w;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
<div class="row collapse">
|
<div class="row collapse">
|
||||||
<div class="large-12 columns">
|
<div class="large-12 columns">
|
||||||
<div ng-if="!$root.wallet.publicKeyRing.isComplete()">
|
<div ng-if="!$root.wallet.isReady()">
|
||||||
<h2>
|
<h2>
|
||||||
<span translate>Waiting copayers for</span>
|
<span translate>Waiting copayers for</span>
|
||||||
{{$root.wallet.getName()}}
|
{{$root.wallet.getName()}}
|
||||||
|
|
@ -18,20 +18,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 ng-if="$root.wallet &&
|
|
||||||
$root.wallet.publicKeyRing.isComplete()">
|
|
||||||
<span translate>Wallet</span> {{$root.wallet.getName()}}
|
|
||||||
<small>{{$root.wallet.requiredCopayers}}-{{'of'|translate}}-{{$root.wallet.totalCopayers}}</small>
|
|
||||||
<span translate>created</span>
|
|
||||||
</h2>
|
|
||||||
<div class="box-setup-copayers p20">
|
<div class="box-setup-copayers p20">
|
||||||
<p class="text-primary m10b"
|
|
||||||
ng-show="$root.wallet && $root.wallet.publicKeyRing.isComplete()" translate>
|
|
||||||
Creating and storing a backup will allow you to recover wallet funds
|
|
||||||
</p>
|
|
||||||
<div class="oh">
|
<div class="oh">
|
||||||
<div ng-include="'views/includes/copayer.html'"></div>
|
<div ng-include="'views/includes/copayer.html'"></div>
|
||||||
<div ng-if="!$root.wallet.publicKeyRing.isComplete()">
|
<div ng-if="!$root.wallet.isReady()">
|
||||||
<img
|
<img
|
||||||
class="waiting br100"
|
class="waiting br100"
|
||||||
src="./img/satoshi.gif"
|
src="./img/satoshi.gif"
|
||||||
|
|
@ -43,70 +33,6 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ng-show="$root.wallet.publicKeyRing.remainingCopayers() > 1">
|
|
||||||
<div class="line-sidebar-b" ng-if="$root.wallet && $root.wallet.publicKeyRing.isComplete()"></div>
|
|
||||||
<div class="text-gray m10t" ng-if="$root.wallet && $root.wallet.publicKeyRing.isComplete()">
|
|
||||||
<i class="text-white fi-loop icon-rotate spinner"></i>
|
|
||||||
<span translate>Waiting for other copayers to make a Backup</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="oh">
|
|
||||||
<a translate class="size-12 text-warning left m20t" ng-really-click="deleteWallet()"
|
|
||||||
ng-really-message="Are you sure to delete this wallet from this computer?">Delete wallet</a>
|
|
||||||
<button class="button primary right m0"
|
|
||||||
ng-click="backup()"
|
|
||||||
ng-show="!$root.wallet.publicKeyRing.isBackupReady() && !hideViewBackup"
|
|
||||||
ng-disabled="!$root.wallet.publicKeyRing.isComplete()">
|
|
||||||
<span translate ng-show="$root.wallet.publicKeyRing.isComplete() && !isSafari">
|
|
||||||
Backup wallet
|
|
||||||
</span>
|
|
||||||
<span translate ng-show="$root.wallet.publicKeyRing.isComplete() && isSafari">
|
|
||||||
View backup
|
|
||||||
</span>
|
|
||||||
<span ng-show="!$root.wallet.publicKeyRing.isComplete()" >
|
|
||||||
<span ng-show="$root.wallet.publicKeyRing.remainingCopayers() > 1">
|
|
||||||
{{ $root.wallet.publicKeyRing.remainingCopayers() }} <span
|
|
||||||
translate>people have</span>
|
|
||||||
</span>
|
|
||||||
<span translate ng-show="$root.wallet.publicKeyRing.remainingCopayers() == 1">
|
|
||||||
One person has
|
|
||||||
</span>
|
|
||||||
<span translate>yet to join.</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<a class="text-primary m15t m20r right" ng-click="skipBackup()"
|
|
||||||
ng-show="!$root.wallet.publicKeyRing.isBackupReady() && !hideViewBackup"
|
|
||||||
ng-disabled="!$root.wallet.publicKeyRing.isComplete()">
|
|
||||||
<span class="size-12" translate ng-show="$root.wallet.publicKeyRing.isComplete()" >
|
|
||||||
Skip Backup
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<button class="button primary"
|
|
||||||
disabled="disabled"
|
|
||||||
ng-show="$root.wallet.publicKeyRing.isBackupReady()">
|
|
||||||
<span ng-show="$root.wallet.publicKeyRing.remainingBackups() > 1">
|
|
||||||
{{ $root.wallet.publicKeyRing.remainingBackups() }} <span
|
|
||||||
translate>people have</span>
|
|
||||||
</span>
|
|
||||||
<span translate ng-show="$root.wallet.publicKeyRing.remainingBackups() == 1">
|
|
||||||
One person has
|
|
||||||
</span>
|
|
||||||
<span translate>yet to backup the wallet.</span>
|
|
||||||
</button>
|
|
||||||
<div ng-show="backupPlainText">
|
|
||||||
<div class="show-for-large-up">
|
|
||||||
<textarea readonly rows="5">{{backupPlainText}}</textarea>
|
|
||||||
<span translate class="size-12">Copy to clipboard</span> <span class="btn-copy" clip-copy="backupPlainText"> </span>
|
|
||||||
</div>
|
|
||||||
<div class="hide-for-large-up m10b">
|
|
||||||
<textarea rows="10">{{backupPlainText}}</textarea>
|
|
||||||
</div>
|
|
||||||
<div translate class="m10v size-12 text-gray text-right">Copy this text as it is in a safe place (notepad or email)</div>
|
|
||||||
</div>
|
|
||||||
<button class="button primary right m0"
|
|
||||||
ng-show="hideViewBackup"
|
|
||||||
ng-click="skipBackup()" translate>Continue</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div> <!-- end of row -->
|
</div> <!-- end of row -->
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue