Chore: Refactoring new device setup (#1111)

* New device refactoring

* Hide hot wallet if RPC is not connected

* Replace old url with new one

* Update new_device_keys.jinja

* Fixes for adding keys to existing device

* Cleanup old code for adding keys

* Fix cypress

* Update cypress

* Fix hot wallet issue

* Hide generate option on add keys hot wallet

Co-authored-by: Kim Neunert <k9ert@gmx.de>
This commit is contained in:
benk10 2021-04-23 20:06:01 +02:00 committed by GitHub
parent 9039e33f11
commit 7dcfbdc3d9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 488 additions and 578 deletions

View file

@ -35,13 +35,13 @@ Cypress.Commands.add("addDevice", (name) => {
// Creating a Device
cy.contains('Select Your Device Type')
cy.get('#trezor_device_card')
cy.get('#step1 > [type="text"]').type("specter")
cy.get('#device-type-searchbar').type("specter")
cy.contains('Select Your Device Type')
cy.get('#trezor_device_card').should('not.have.class', 'disabled')
cy.get('#specter_device_card').click()
cy.get('h2 > input').type(name)
cy.get('#wizard-previous').click()
cy.get('#step1 > .note').click()
cy.go('back')
cy.get('#device-type-container > .note').click()
cy.get('#device_name').type(name)
cy.get('#device_type').select("Specter-DIY")
cy.get('#txt').type("[6ea15da6/84h/1h/0h]vpub5Yw9Qps1aUVBpD3eyKVUhe5K8gbaWav9ArB4FudKeCLPb5vSsX8afAWfYvEASbkv4qxKzxSRyd3wqdgM3ir2fbrUJwiHDAJUNWGeqMDQfTj")
@ -64,9 +64,9 @@ Cypress.Commands.add("addHotDevice", (name) => {
// Creating a Device
cy.contains('Select Your Device Type')
cy.get('#bitcoincore_device_card').click()
cy.get('#wizard-next').click()
cy.get('#submit-mnemonic').click()
cy.get('#device_name').type(name)
cy.get('#wizard-submit').click()
cy.get('#submit-keys').click()
cy.get('#devices_list > .item > div').contains(name)
})
})

32
package-lock.json generated
View file

@ -1,7 +1,7 @@
{
"name": "specter-desktop-cypress-testing",
"version": "0.0.1",
"lockfileVersion": 2,
"lockfileVersion": 1,
"requires": true,
"packages": {
"": {
@ -3483,21 +3483,6 @@
"tweetnacl": "~0.14.0"
}
},
"string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"requires": {
"safe-buffer": "~5.1.0"
},
"dependencies": {
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
}
}
},
"string-width": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz",
@ -3523,6 +3508,21 @@
}
}
},
"string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"requires": {
"safe-buffer": "~5.1.0"
},
"dependencies": {
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
}
}
},
"strip-ansi": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",

View file

@ -43,6 +43,10 @@ class BitcoinCore(Device):
keys_range=[0, 1000],
keys_purposes=[],
):
if type(keys_range[0]) == str:
keys_range[0] = int(keys_range[0])
if type(keys_range[1]) == str:
keys_range[1] = int(keys_range[1])
seed = bip39.mnemonic_to_seed(mnemonic, passphrase)
root = bip32.HDKey.from_seed(seed)
network = networks.NETWORKS["test" if testnet else "main"]

View file

@ -1,4 +1,4 @@
import copy, random
import copy, random, json
from flask import (
Flask,
@ -15,6 +15,8 @@ from flask import current_app as app
from mnemonic import Mnemonic
from ..helpers import is_testnet, generate_mnemonic
from ..key import Key
from ..device_manager import get_device_class
from ..devices.bitcoin_core import BitcoinCore
from ..wallet_manager import purposes
from ..specter_error import handle_exception
@ -25,138 +27,195 @@ devices_endpoint = Blueprint("devices_endpoint", __name__)
################## New device #######################
# New device
@devices_endpoint.route("/new_device/", methods=["GET", "POST"])
# New device type
@devices_endpoint.route("/new_device_type/", methods=["GET", "POST"])
@login_required
def new_device():
def new_device_type():
return render_template(
"device/new_device/new_device_type.jinja",
specter=app.specter,
rand=rand,
)
@devices_endpoint.route("/new_device_keys/<device_type>", methods=["GET", "POST"])
@login_required
def new_device_keys(device_type):
err = None
strength = 128
mnemonic = generate_mnemonic(strength=strength)
mnemonic = ""
passphrase = ""
file_password = ""
range_start = 0
range_end = 1000
existing_device = None
if request.method == "POST":
if request.form.get("existing_device"):
device = app.specter.device_manager.get_by_alias(
request.form.get("existing_device")
)
device_type = device.device_type
mnemonic = request.form.get("mnemonic", "")
passphrase = request.form.get("passphrase", "")
file_password = request.form.get("file_password", "")
range_start = int(request.form.get("range_start", "0"))
range_end = int(request.form.get("range_end", "1000"))
existing_device = request.form.get("existing_device", None)
if existing_device:
device = app.specter.device_manager.get_by_alias(existing_device)
else:
device_type = request.form.get("devices")
device_name = request.form.get("device_name", "")
if not device_name:
err = "Device name must not be empty"
elif device_name in app.specter.device_manager.devices_names:
err = "Device with this name already exists"
xpubs_rows_count = int(request.form["xpubs_rows_count"]) + 1
if device_type != "bitcoincore":
keys = []
for i in range(0, xpubs_rows_count):
purpose = request.form.get(
"xpubs-table-row-{}-purpose".format(i), "Custom"
)
xpub = request.form.get("xpubs-table-row-{}-xpub-hidden".format(i), "-")
if xpub != "-":
try:
keys.append(Key.parse_xpub(xpub, purpose=purpose))
except:
err = "Failed to parse these xpubs:\n" + "\n".join(xpub)
break
if not keys and not err:
err = "xpubs name must not be empty"
if err is None:
if request.form.get("existing_device"):
device.add_keys(keys)
flash("{} keys were added successfully".format(len(keys)))
return redirect(
url_for("devices_endpoint.device", device_alias=device.alias)
keys = []
paths = []
keys_purposes = []
for i in range(0, xpubs_rows_count):
purpose = request.form.get("xpubs-table-row-{}-purpose".format(i), "Custom")
xpub = request.form.get("xpubs-table-row-{}-xpub-hidden".format(i), "-")
path = request.form.get(
"xpubs-table-row-{}-derivation-hidden".format(i), ""
)
if path != "":
paths.append(path)
keys_purposes.append(purpose)
if xpub != "-":
try:
keys.append(Key.parse_xpub(xpub, purpose=purpose))
except:
err = "Failed to parse these xpubs:\n" + "\n".join(xpub)
break
if not keys and not err:
if device_type == "bitcoincore":
if not paths:
err = "No paths were specified, please provide at lease one."
if err is None:
if existing_device:
device.add_hot_wallet_keys(
mnemonic,
passphrase,
paths,
file_password,
app.specter.wallet_manager,
is_testnet(app.specter.chain),
keys_range=[range_start, range_end],
keys_purposes=keys_purposes,
)
flash("{} keys were added successfully".format(len(paths)))
return redirect(
url_for(
"devices_endpoint.device", device_alias=device.alias
)
)
device = app.specter.device_manager.add_device(
name=device_name, device_type=device_type, keys=[]
)
device = app.specter.device_manager.add_device(
name=device_name, device_type=device_type, keys=keys
)
flash("{} was added successfully!".format(device_name))
try:
device.setup_device(file_password, app.specter.wallet_manager)
device.add_hot_wallet_keys(
mnemonic,
passphrase,
paths,
file_password,
app.specter.wallet_manager,
is_testnet(app.specter.chain),
keys_range=[range_start, range_end],
keys_purposes=keys_purposes,
)
flash("{} was added successfully!".format(device_name))
return redirect(
url_for(
"devices_endpoint.device", device_alias=device.alias
)
+ "?newdevice=true"
)
except Exception as e:
handle_exception(e)
flash(f"Failed to setup hot wallet. Error: {e}", "error")
app.specter.device_manager.remove_device(
device,
app.specter.wallet_manager,
bitcoin_datadir=app.specter.bitcoin_datadir,
chain=app.specter.chain,
)
else:
err = "xpubs list must not be empty"
elif not err:
if existing_device:
device.add_keys(keys)
flash("{} keys were added successfully".format(len(keys)))
return redirect(
url_for("devices_endpoint.device", device_alias=device.alias)
+ "?newdevice=true"
)
else:
flash(err, "error")
else:
if len(request.form["mnemonic"].split(" ")) not in [12, 15, 18, 21, 24]:
err = "Invalid mnemonic entered: Must contain either: 12, 15, 18, 21, or 24 words."
mnemo = Mnemonic("english")
if not mnemo.check(request.form["mnemonic"]):
err = "Invalid mnemonic entered."
range_start = int(request.form["range_start"])
range_end = int(request.form["range_end"])
if range_start > range_end:
err = "Invalid address range selected."
mnemonic = request.form["mnemonic"]
paths = []
keys_purposes = []
for i in range(0, xpubs_rows_count):
purpose = request.form.get(
"xpubs-table-row-{}-purpose".format(i), "Custom"
)
path = request.form.get(
"xpubs-table-row-{}-derivation-hidden".format(i), ""
)
if path != "":
paths.append(path)
keys_purposes.append(purpose)
if not paths:
err = "No paths were specified, please provide at lease one."
if err is None:
passphrase = request.form["passphrase"]
file_password = request.form["file_password"]
if request.form.get("existing_device"):
device.add_hot_wallet_keys(
mnemonic,
passphrase,
paths,
file_password,
app.specter.wallet_manager,
is_testnet(app.specter.chain),
keys_range=[range_start, range_end],
keys_purposes=keys_purposes,
)
flash("{} keys were added successfully".format(len(paths)))
return redirect(
url_for("devices_endpoint.device", device_alias=device.alias)
)
device = app.specter.device_manager.add_device(
name=device_name, device_type=device_type, keys=[]
)
try:
device.setup_device(file_password, app.specter.wallet_manager)
device.add_hot_wallet_keys(
mnemonic,
passphrase,
paths,
file_password,
app.specter.wallet_manager,
is_testnet(app.specter.chain),
keys_range=[range_start, range_end],
keys_purposes=keys_purposes,
)
flash("{} was added successfully!".format(device_name))
return redirect(
url_for("devices_endpoint.device", device_alias=device.alias)
+ "?newdevice=true"
)
except Exception as e:
handle_exception(e)
flash(f"Failed to setup hot wallet. Error: {e}", "error")
app.specter.device_manager.remove_device(
device,
app.specter.wallet_manager,
bitcoin_datadir=app.specter.bitcoin_datadir,
chain=app.specter.chain,
)
else:
flash(err, "error")
device = app.specter.device_manager.add_device(
name=device_name, device_type=device_type, keys=keys
)
flash("{} was added successfully!".format(device_name))
return redirect(
url_for("devices_endpoint.device", device_alias=device.alias)
+ "?newdevice=true"
)
return render_template(
"device/new_device.jinja",
"device/new_device/new_device_keys.jinja",
device_class=get_device_class(device_type),
mnemonic=mnemonic,
passphrase=passphrase,
file_password=file_password,
range_start=range_start,
range_end=range_end,
existing_device=app.specter.device_manager.get_by_alias(existing_device)
if existing_device
else None,
error=err,
specter=app.specter,
rand=rand,
)
@devices_endpoint.route("/new_device_mnemonic/", methods=["GET", "POST"])
@login_required
def new_device_mnemonic():
err = None
strength = 128
mnemonic = generate_mnemonic(strength=strength)
existing_device = None
if request.method == "POST":
if len(request.form["mnemonic"].split(" ")) not in [12, 15, 18, 21, 24]:
err = "Invalid mnemonic entered: Must contain either: 12, 15, 18, 21, or 24 words."
mnemo = Mnemonic("english")
if not mnemo.check(request.form["mnemonic"]):
err = "Invalid mnemonic entered."
range_start = int(request.form["range_start"])
range_end = int(request.form["range_end"])
if range_start > range_end:
err = "Invalid address range selected."
mnemonic = request.form["mnemonic"]
passphrase = request.form["passphrase"]
file_password = request.form["file_password"]
existing_device = request.form.get("existing_device", None)
print("file_password")
print(file_password)
if existing_device:
existing_device = app.specter.device_manager.get_by_alias(existing_device)
if not err:
return render_template(
"device/new_device/new_device_keys.jinja",
mnemonic=mnemonic,
passphrase=passphrase,
file_password=file_password,
range_start=range_start,
range_end=range_end,
device_class=BitcoinCore,
existing_device=existing_device,
error=err,
specter=app.specter,
rand=rand,
)
return render_template(
"device/new_device/new_device_mnemonic.jinja",
strength=strength,
mnemonic=mnemonic,
existing_device=existing_device,
error=err,
specter=app.specter,
rand=rand,
)
@ -312,62 +371,26 @@ def device(device_alias):
elif action == "add_keys":
strength = 128
mnemonic = generate_mnemonic(strength=strength)
return render_template(
"device/new_device.jinja",
mnemonic=mnemonic,
strength=strength,
existing_device=device,
device_alias=device_alias,
specter=app.specter,
rand=rand,
)
elif action == "morekeys":
if device.hot_wallet:
if len(request.form["mnemonic"].split(" ")) not in [12, 15, 18, 21, 24]:
err = "Invalid mnemonic entered: Must contain either: 12, 15, 18, 21, or 24 words."
mnemo = Mnemonic("english")
if not mnemo.check(request.form["mnemonic"]):
err = "Invalid mnemonic entered."
range_start = int(request.form["range_start"])
range_end = int(request.form["range_end"])
if range_start > range_end:
err = "Invalid address range selected."
if err is None:
mnemonic = request.form["mnemonic"]
paths = [
l.strip()
for l in request.form["derivation_paths"].split("\n")
if len(l) > 0
]
passphrase = request.form["passphrase"]
file_password = request.form["file_password"]
device.add_hot_wallet_keys(
mnemonic,
passphrase,
paths,
file_password,
app.specter.wallet_manager,
is_testnet(app.specter.chain),
keys_range=[range_start, range_end],
)
return render_template(
"device/new_device/new_device_mnemonic.jinja",
mnemonic=mnemonic,
strength=strength,
existing_device=device,
device_alias=device_alias,
device_class=get_device_class(device.device_type),
specter=app.specter,
rand=rand,
)
else:
# refactor to fn
xpubs = request.form["xpubs"]
keys, failed = Key.parse_xpubs(xpubs)
err = None
if len(failed) > 0:
err = "Failed to parse these xpubs:\n" + "\n".join(failed)
return render_template(
"device/new_device_manual.jinja",
device=device,
device_alias=device_alias,
xpubs=xpubs,
error=err,
specter=app.specter,
rand=rand,
)
if err is None:
device.add_keys(keys)
return render_template(
"device/new_device/new_device_keys.jinja",
existing_device=device,
device_alias=device_alias,
device_class=get_device_class(device.device_type),
specter=app.specter,
rand=rand,
)
elif action == "settype":
device_type = request.form["device_type"]
device.set_type(device_type)

View file

@ -165,7 +165,7 @@
<img src="{{ url_for('static', filename='img/checkbox-' + ('un' if not (specter.device_manager.devices | length) else '') + 'tick.svg') }}" width="30px">
</td>
<td style="text-align: left;">
<a href="{{ url_for('devices_endpoint.new_device') }}">
<a href="{{ url_for('devices_endpoint.new_device_type') }}">
<h2>Add signing devices you wish to use.</h2>
</a>
</td>

View file

@ -100,7 +100,7 @@
<div class="hidden" id="new_device_popup">
<h1>New device was added successfully!</h1>
<div class="row overflow">
<a href="{{ url_for('devices_endpoint.new_device') }}" style="text-decoration: none;">
<a href="{{ url_for('devices_endpoint.new_device_type') }}" style="text-decoration: none;">
<div class="small-card radio">
<img src="{{ url_for('static', filename='img/plus.svg') }}" class="svg-white" style="transform: scale(1.5);">
Add another device

View file

@ -32,78 +32,26 @@
visibility: hidden;
}
</style>
<div id="device_setup_wizard" style="width: 100%;" onsubmit="showPacman();">
<h1 style="font-size: 2em;">Add {% if existing_device %}Keys{% else %}Device{% endif %}</h1><br>
<form id="form" action="{{ url_for('devices_endpoint.new_device') }}" method="POST" class="card center" style="width: auto; margin: 40px;">
<form id="form" action="{{ url_for('devices_endpoint.new_device_keys', device_type=device_class.device_type) }}" method="POST" style="width: 100%;" onsubmit="showPacman();">
<div style="width: auto; margin: 40px;" class="card center" >
{% if existing_device %}
<input type="hidden" name="existing_device" value="{{ existing_device.alias }}">
{% endif %}
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
<div {% if not existing_device %}id="step1"{% else %}class="hidden"{% endif %}>
<h1 style="font-size: 1.8em;">Select Your Device Type</h1>
<input type="text" placeholder="Filter devices..." style="width: 60%; font-size: 1.4em; padding: 10px;" oninput="filterDeviceTypes(this.value)">
<div id="devices_list" class="row overflow">
{% for cls in specter.device_manager.supported_devices %}
<label id="label_device_type_{{ cls.name }}">
<input onchange="setStepCompleted(1);nextStep();" type="radio" name="devices" value="{{ cls.device_type }}" data-qrcode="{{cls.qr_code_support}}" data-sdcard="{{cls.sd_card_support}}" data-hwi="{{cls.hwi_support}}" data-device-name="{{ cls.name }}" class="hidden device-type">
<div class="small-card radio" id="{{ cls.device_type }}_device_card" style="transform: scale(0.85); margin: 3px;">
<img src="{{ url_for('static', filename='img/devices/' ~ cls.icon) }}" width="18px">
{{ cls.name }}
</div>
</label>
{% endfor %}
</div>
<a class="note center" style="text-decoration: underline; cursor: pointer;" href="{{ url_for('devices_endpoint.new_device_manual') }}">Manual configuration</a>
</div>
<div {% if not existing_device %}id="step2-hot-wallet" class="hidden"{% elif existing_device.hot_wallet %}id="step1"{% else %}class="hidden"{% endif %}>
<div id="hot-wallet" style="width: 80%; max-width: 700px; margin: auto;">
<br>
<p class="note" style="border-radius: 5px; padding: 20px; background: rgba(0,0,0,0.1); color: #ccc;">
<span style="display: inline-block;font-weight: bold; margin-bottom: 8px;">⚠️ A note on hot wallets:</span><br>
This will create a <a style="color: #ddd;" href="https://en.bitcoin.it/wiki/Hot_wallet" target="_blank">hot wallet</a> on your Bitcoin Core node.<br>
Hot wallets are considered less secure and are not recommended for use with significant amounts. Use with caution and beware the potential risks.
</p>
<p>Enter you mnemonic here or use the randomly generated one:</p>
<p><b>Make sure to backup these words!</b></p>
{% set wordslist = mnemonic.split(' ') %}
<nav class="row">
<button type="button" id="generate_mnemonic_btn" class="btn radio left checked" onclick="setMnemonicView('generate')"> Generate </button>
<button type="button" id="import_mnemonic_btn" class="btn radio right" onclick="setMnemonicView('import')"> Import </button>
</nav>
<input id="mnemonic_value" type="hidden" name="mnemonic" value="{{ mnemonic }}"/>
<textarea id="import_mnemonic" class="hidden" id="txt" placeholder="Enter your mnemonic here"></textarea>
<div id="generated_mnemonic">
<div id="generated_mnemonic_row" class="row center break-row-mobile" style="max-height: 30px;">
<fieldset style="border:none; display: inline;">
<select name="strength" id="strength">
<option value="128">12 words</option>
<option value="256">24 words</option>
</select>
</fieldset>
<button type="button" class="btn" style="max-width: 200px; margin-top: 9px;" onclick="generateMnemonic()">Generate New Mnemonic</button>
</div>
<br>
<table id="mnemonic_table">
{% for i in range(wordslist|length // 4) %}
<tr>
<td id="mnemonicwo">{{ 4 * i + 1 }}. {{ wordslist[4 * i] }}</td>
<td>{{ 4 * i + 2 }}. {{ wordslist[4 * i + 1] }}</td>
<td>{{ 4 * i + 3 }}. {{ wordslist[4 * i + 2] }}</td>
<td>{{ 4 * i + 4 }}. {{ wordslist[4 * i + 3] }}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
</div>
<div {% if not existing_device or existing_device.hot_wallet %}id="step2" class="hidden"{% else %}id="step1"{% endif %}>
{% if device_class.hot_wallet %}
<input type="hidden" name="mnemonic" value="{{ mnemonic }}"/>
<input type="hidden" name="passphrase" value="{{ passphrase }}"/>
<input type="hidden" name="file_password" value="{{ file_password }}"/>
<input type="hidden" name="range_start" value="{{ range_start }}"/>
<input type="hidden" name="range_end" value="{{ range_end }}"/>
{% endif %}
<div>
<h1 style="font-size: 1.8em;">Upload Keys</h1>
{% if not existing_device %}
<h2 style="max-width: 500px; margin: auto;">Name your Device: <input type="text" name="device_name" placeholder="My device" id="device_name" required/></h2>
<br>
{% endif %}
<div id="coldcard-instructions">
<div id="coldcard-instructions" {%if device_class.device_type != 'coldcard' %}class="hidden"{% endif %}>
<p>
Connect your ColdCard to the computer via USB and unlock it or upload a wallet export file from micro SD card.<br>
</p>
@ -113,7 +61,7 @@
To get the multisig xpubs, go to: Settings -> Multisig Wallets -> Export XPUB and confirm the export.
</span>
</div>
<div id="specter-instructions">
<div id="specter-instructions" {%if device_class.device_type != 'specter' %}class="hidden"{% endif %}>
<p>
Connect your Specter-DIY to the computer via USB and unlock it or scan the wallet master public key.<br>
</p>
@ -121,7 +69,7 @@
To get the master public key QR code to scan, click on: Master public keys, then select each key type you'd like to import and scan the displayed QR code for each into Specter.
</span>
</div>
<div id="cobo-instructions">
<div id="cobo-instructions" {%if device_class.device_type != 'cobo' %}class="hidden"{% endif %}>
<p>
Scan your Cobo Vault master public key or upload a wallet export file.<br>
</p>
@ -133,7 +81,7 @@
To import with SD card, click on "touch here to export the file with microSD" on the same screen as the QR code.
</span>
</div>
<div id="hwi-only-instructions">
<div id="hwi-only-instructions" {%if device_class.device_type in ['bitcoincore', 'cobo', 'coldcard', 'electrum', 'other', 'specter']%}class="hidden"{% endif %}>
<p>Connect your hardware device to the computer via USB.</p>
</div>
<br>
@ -149,26 +97,26 @@
</table>
<br>
<div class="row overflow">
<div id="connect-hwi" class="btn" style="max-width: 250px; width: 250px; margin: 10px;">
<div id="connect-hwi" style="max-width: 250px; width: 250px; margin: 10px;" class="btn {% if not device_class.hwi_support %}hidden{% endif %}">
<img src="{{ url_for('static', filename='img/usb.svg') }}" style="width: 26px; margin-right: 2px;" class="svg-white">
Get via USB
</div>
<label id="connect-sdcard">
<label id="connect-sdcard" {% if not device_class.sd_card_support %}class="hidden"{% endif %}>
<input type="file" id="file" class="inputfile" multiple/>
<div class="btn" style="max-width: 250px; width: 250px; margin: 10px;">
<img src="{{ url_for('static', filename='img/sd-card.svg') }}" style="width: 26px; margin: 0px;" class="svg-white">
Upload from SD Card
</div>
</label>
<qr-scanner id="xpub-scan">
<qr-scanner id="xpub-scan" {% if not device_class.qr_code_support %}class="hidden"{% endif %}>
<a slot="button" href="#" class="btn" style="max-width: 250px; width: 250px; margin: 10px;">
<img src="{{ url_for('static', filename='img/qr-code.svg') }}" style="width: 26px; margin: 0px;" class="svg-white">
Scan QR code
</a>
</qr-scanner>
<button type="button" onclick="showPageOverlay('paste-xpub-popup')" class="btn centered" id="xpub-paste" style="max-width: 250px; width: 250px; margin: 10px;">
<img src="{{ url_for('static', filename='img/copy.svg') }}" style="width: 22px; margin: 0px;" class="svg-white">
Paste xpub
<button {% if device_class.device_type not in ['electrum', 'other'] %}class="hidden"{% endif %} type="button" onclick="showPageOverlay('paste-xpub-popup')" class="btn centered" id="xpub-paste" style="max-width: 250px; width: 250px; margin: 10px;">
<img src="{{ url_for('static', filename='img/copy.svg') }}" style="width: 22px; margin: 0px;" class="svg-white">
Paste xpub
</button>
<div class="hidden" id="paste-xpub-popup">
<h1>Paste xpub</h1>
@ -177,32 +125,11 @@
<button type="button" onclick="addXpubs(document.getElementById('paste-xpub-text').value);hidePageOverlay();document.getElementById('paste-xpub-text').value='';" class="btn centered">Add xpub</button>
</div>
</div>
<span id="toggle_advanced_xpubs" style="cursor: pointer;" onclick="toggleAdvancedXpubs()">Advanced &#9654;</span>
<br>
<div id="advanced_settings_xpubs" class="hidden">
<div id="advanced-hot-wallet" class="hidden" style="width: 400px; margin: auto;">
<label>Encrypt Wallet File: </label>
<input type="password" name="file_password" class="inline" placeholder="password">
<br>
<br>
<label>BIP39 passphrase (optional): </label>
<input type="password" name="passphrase" class="inline" placeholder="passphrase">
<br>
<p style="margin-bottom: 10px;">Addresses range to import: </p>
From: <input type="number" name="range_start" class="inline" placeholder="start" value=0 step=1 min=0 max=100000>
to: <input type="number" name="range_end" class="inline" placeholder="start" value=1000 step=1 min=0 max=100000>
<br>
</div>
</div>
</div>
<br>
<div class="row">
<button type="button" class="btn hidden" style="width: 140px; max-width: 140px; margin: auto; margin-right: 40px;" id="wizard-previous" onclick="prevStep()">Previous</button>
<button type="button" class="btn" style="width: 140px; max-width: 140px; margin: auto;" id="wizard-next" onclick="nextStep()" disabled>Next</button>
<button type="submit" class="btn centered action hidden" style="width: 140px; max-width: 140px; margin: auto;" id="wizard-submit">Add {% if existing_device %}Keys{% else %}Device{% endif %}</button>
</div>
</form>
</div>
</div>
<button type="submit" id="submit-keys" class="btn centered action" style="width: 140px; max-width: 140px; margin: auto;">Continue</button>
<br><br>
</form>
{% endblock %}
{% block scripts %}
@ -346,265 +273,6 @@
setupXpubsTable();
});
function setMnemonicView(view) {
var generatedMnemonic = document.getElementById("generated_mnemonic");
var importMnemonic = document.getElementById("import_mnemonic");
var generateMnemonicBtn = document.getElementById("generate_mnemonic_btn");
var importMnemonicBtn = document.getElementById("import_mnemonic_btn");
var mnemonicValue = document.getElementById("mnemonic_value");
if (view == 'generate') {
generatedMnemonic.style.display = 'block';
importMnemonic.style.display = 'none';
generateMnemonicBtn.className = generateMnemonicBtn.className += ' checked';
importMnemonicBtn.className = importMnemonicBtn.className.replace(/checked/, '');
mnemonicValue.value = '{{ mnemonic }}'
} else {
generatedMnemonic.style.display = 'none';
importMnemonic.style.display = 'block';
generateMnemonicBtn.className = generateMnemonicBtn.className.replace(/checked/, '');
importMnemonicBtn.className = importMnemonicBtn.className += ' checked';
mnemonicValue.value = importMnemonic.value;
importMnemonic.addEventListener("input", function() {
mnemonicValue.value = importMnemonic.value;
});
}
}
async function generateMnemonic() {
let url = `{{ url_for('generatemnemonic') }}`;
var formData = new FormData(document.getElementById('form'));
try {
const response = await fetch(
url,
{
method: 'POST',
body: formData
}
);
if(response.status != 200){
showError(await response.text());
return;
}
const jsonResponse = await response.json();
let mnemonic = jsonResponse.mnemonic;
document.getElementById("mnemonic_value").value = mnemonic;
let wordslist = mnemonic.split(' ');
let mnemonicTable = document.getElementById("mnemonic_table");
mnemonicTable.innerHTML = ``;
for(let i=0; i < wordslist.length / 4; i++) {
mnemonicTable.innerHTML += `
<tr>
<td id="mnemonicwo">${ 4 * i + 1 }. ${ wordslist[4 * i] }</td>
<td>${ 4 * i + 2 }. ${ wordslist[4 * i + 1] }</td>
<td>${ 4 * i + 3 }. ${ wordslist[4 * i + 2] }</td>
<td>${ 4 * i + 4 }. ${ wordslist[4 * i + 3] }</td>
</tr>
`
}
} catch(e) {
console.log("Caught error: ", e);
showError(e);
}
}
// Wizard JS
let currentStep = 1;
let totalSteps = parseInt('{{ 1 if existing_device and not existing_device.hot_wallet else 2 }}');
let stepsCompleted = [];
{% if existing_device %}
{% if existing_device.hot_wallet %}
setStepCompleted(1);
{% else %}
nextStep();
{% endif %}
{% endif %}
function checkStepCompleted(step) {
}
function setStepCompleted(step) {
document.getElementById('wizard-next').removeAttribute('disabled');
stepsCompleted.push(step);
}
function setStepUncompleted(step) {
document.getElementById('wizard-next').setAttribute('disabled', true);
delete stepsCompleted[(stepsCompleted.indexOf(step))];
}
function nextStep() {
if (currentStep == 1) {
{% if existing_device %}
let device = {
value: "{{ existing_device.device_type }}",
'data-qrcode': "{{existing_device.qr_code_support}}",
'data-sdcard': "{{existing_device.sd_card_support}}",
'data-hwi': "{{existing_device.hwi_support}}",
'data-device-name': "{{ existing_device.name }}"
}
{% else %}
var devices = document.getElementsByName('devices');
let device;
for (var i = 0, length = devices.length; i < length; i++) {
if (devices[i].checked) {
device = devices[i];
break;
}
}
device = {
value: device.value,
'data-qrcode': device.getAttribute('data-qrcode'),
'data-sdcard': device.getAttribute('data-sdcard'),
'data-hwi': device.getAttribute('data-hwi'),
'data-device-name': device.getAttribute('data-device-name')
}
{% endif %}
if (device.value == 'bitcoincore') {
if (document.getElementById('step2-hot-wallet')) {
document.getElementById('step2').id = 'step3';
document.getElementById('step2-hot-wallet').id = 'step2';
}
document.getElementById('toggle_advanced_xpubs').style.display = 'block';
totalSteps = parseInt('{{ 2 if existing_device else 3 }}');
} else {
if (document.getElementById('step3')) {
document.getElementById('step3').id = 'step2';
document.getElementById('step2').id = 'step2-hot-wallet';
}
document.getElementById('toggle_advanced_xpubs').style.display = 'none';
totalSteps = parseInt('{{ 1 if existing_device else 2 }}');
}
// Update connect methods for step 2
if (device['data-hwi'] == 'True') {
document.getElementById('connect-hwi').classList.remove('hidden');
} else {
document.getElementById('connect-hwi').classList.add('hidden');
}
if (device['data-sdcard'] == 'True') {
document.getElementById('connect-sdcard').classList.remove('hidden');
} else {
document.getElementById('connect-sdcard').classList.add('hidden');
}
if (device['data-qrcode'] == 'True') {
document.getElementById('xpub-scan').classList.remove('hidden');
} else {
document.getElementById('xpub-scan').classList.add('hidden');
}
if (device.value == 'electrum' || device.value == 'other') {
document.getElementById('xpub-paste').classList.remove('hidden');
} else {
document.getElementById('xpub-paste').classList.add('hidden');
}
document.getElementById('specter-instructions').classList.add('hidden');
document.getElementById('coldcard-instructions').classList.add('hidden');
document.getElementById('cobo-instructions').classList.add('hidden');
document.getElementById('hot-wallet').classList.add('hidden');
document.getElementById('advanced-hot-wallet').classList.add('hidden');
document.getElementById('hwi-only-instructions').classList.add('hidden');
switch(device.value) {
case 'specter':
document.getElementById('specter-instructions').classList.remove('hidden');
break;
case 'coldcard':
document.getElementById('coldcard-instructions').classList.remove('hidden');
break;
case 'cobo':
document.getElementById('cobo-instructions').classList.remove('hidden');
break;
case 'electrum':
case 'other':
break;
case 'bitcoincore':
document.getElementById('hot-wallet').classList.remove('hidden');
document.getElementById('advanced-hot-wallet').classList.remove('hidden');
break;
default:
document.getElementById('hwi-only-instructions').classList.remove('hidden');
}
}
if (currentStep < totalSteps) {
document.getElementById('wizard-previous').classList.remove('hidden');
document.getElementById(`step${currentStep}`).classList.add('hidden');
document.getElementById(`step${currentStep + 1}`).classList.remove('hidden');
if (!document.getElementById('step3')) { // Only bitcoin core hot wallet
document.getElementById('wizard-next').setAttribute('disabled', 'true');
}
currentStep++;
}
if (currentStep in stepsCompleted) {
setStepCompleted(currentStep);
}
if (currentStep == totalSteps) {
document.getElementById('wizard-next').classList.add('hidden');
document.getElementById('wizard-submit').classList.remove('hidden');
let deviceName;
let deviceType;
var devices = document.getElementsByName('devices');
for (var i = 0, length = devices.length; i < length; i++) {
if (devices[i].checked) {
deviceName = devices[i]['data-device-name'];
deviceType = devices[i].value;
setupXpubsTable(deviceType);
break;
}
}
if (deviceType == 'other' || deviceType == 'electrum') {
xpubsEditable = true;
} else {
xpubsEditable = false;
}
}
}
function prevStep() {
document.getElementById(`step${currentStep}`).classList.add('hidden');
document.getElementById(`step${currentStep - 1}`).classList.remove('hidden');
currentStep--;
if (currentStep == 1) {
document.getElementById('wizard-previous').classList.add('hidden');
}
setStepCompleted(currentStep);
document.getElementById('wizard-next').classList.remove('hidden');
document.getElementById('wizard-submit').classList.add('hidden');
}
function filterDeviceTypes(text) {
let devicesLabels = []
{% for cls in specter.device_manager.supported_devices %}
devicesLabels.push('label_device_type_{{ cls.name }}')
{% endfor %}
if (text) {
for (let deviceLabel of devicesLabels) {
if (deviceLabel.split('label_device_type_')[1].toLowerCase().includes(text.toLowerCase())) {
document.getElementById(deviceLabel).style.display = 'flex';
} else {
document.getElementById(deviceLabel).style.display = 'none';
}
}
} else {
for (let deviceLabel of devicesLabels) {
document.getElementById(deviceLabel).style.display = 'flex';
}
}
}
function editXpubsTable(editBtn) {
let visibility = '';
if (editBtn.innerHTML == 'Edit') {
@ -621,23 +289,6 @@
el.style.visibility = visibility;
}
}
function toggleAdvancedXpubs() {
let advancedButton = document.getElementById('toggle_advanced_xpubs');
let advancedSettings = document.getElementById('advanced_settings_xpubs');
if (advancedSettings.style.display === 'block') {
advancedSettings.style.display = 'none';
advancedButton.innerHTML = 'Advanced &#9654;';
} else {
advancedSettings.style.display = 'block';
advancedButton.innerHTML = 'Advanced &#9660;';
if (totalSteps == 3) {
document.getElementById('advanced-hot-wallet').display = 'block';
} else {
document.getElementById('advanced-hot-wallet').display = 'none';
}
}
}
</script>
<script type="text/javascript">

View file

@ -0,0 +1,191 @@
{% extends "base.jinja" %}
{% block main %}
{% include "includes/hwi/hwi.jinja" %}
{% include "includes/qr-scanner.html" %}
<style>
td {
text-align: left;
}
@media screen and (max-width:720px) {
#device_setup_wizard th, #device_setup_wizard td {
display: block;
width: 100%;
}
#device_setup_wizard td:empty, #device_setup_wizard th:empty {
display: none;
margin: auto;
}
#device_setup_wizard th, #device_setup_wizard td, #device_setup_wizard td > input {
text-align: center;
width: 100%;
margin: auto;
}
#device_setup_wizard .xpub {
padding: 10px;
}
#device_setup_wizard #edit-xpubs-table-btn {
margin: auto;
}
}
.xpubs_edit {
visibility: hidden;
}
</style>
<form id="form" action="{{ url_for('devices_endpoint.new_device_mnemonic') }}" method="POST" style="width: 100%;" onsubmit="showPacman();">
<div class="card center" style="width: auto; margin: 40px;" >
<h1 style="font-size: 2em;">Add {% if existing_device %}Keys{% else %}Device{% endif %}</h1><br>
{% if existing_device %}
<input type="hidden" name="existing_device" value="{{ existing_device.alias }}">
{% endif %}
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
<div>
<div id="hot-wallet" style="width: 80%; max-width: 700px; margin: auto;">
<br>
<p class="note" style="border-radius: 5px; padding: 20px; background: rgba(0,0,0,0.1); color: #ccc;">
<span style="display: inline-block;font-weight: bold; margin-bottom: 8px;">⚠️ A note on hot wallets:</span><br>
This will create a <a style="color: #ddd;" href="https://en.bitcoin.it/wiki/Hot_wallet" target="_blank">hot wallet</a> on your Bitcoin Core node.<br>
Hot wallets are considered less secure and are not recommended for use with significant amounts. Use with caution and beware the potential risks.
</p>
<p>Enter your mnemonic here{% if not existing_device %} or use the randomly generated one{% endif %}:</p>
{% if not existing_device %}<p><b>Make sure to backup these words!</b></p>{% endif %}
{% set wordslist = mnemonic.split(' ') %}
{% if not existing_device %}
<nav class="row">
<button type="button" id="generate_mnemonic_btn" class="btn radio left checked" onclick="setMnemonicView('generate')"> Generate </button>
<button type="button" id="import_mnemonic_btn" class="btn radio right" onclick="setMnemonicView('import')"> Import </button>
</nav>
{% endif %}
<input id="mnemonic_value" type="hidden" name="mnemonic" value="{{ mnemonic }}"/>
<textarea id="import_mnemonic" class="hidden" id="txt" placeholder="Enter your mnemonic here"></textarea>
<div id="generated_mnemonic" {% if existing_device %}class="hidden"{% endif %}>
<div id="generated_mnemonic_row" class="row center break-row-mobile" style="max-height: 30px;">
<fieldset style="border:none; display: inline;">
<select name="strength" id="strength">
<option value="128">12 words</option>
<option value="256">24 words</option>
</select>
</fieldset>
<button type="button" class="btn" style="max-width: 200px; margin-top: 9px;" onclick="generateMnemonic()">Generate New Mnemonic</button>
</div>
<br>
<table id="mnemonic_table">
{% for i in range(wordslist|length // 4) %}
<tr>
<td id="mnemonicwo">{{ 4 * i + 1 }}. {{ wordslist[4 * i] }}</td>
<td>{{ 4 * i + 2 }}. {{ wordslist[4 * i + 1] }}</td>
<td>{{ 4 * i + 3 }}. {{ wordslist[4 * i + 2] }}</td>
<td>{{ 4 * i + 4 }}. {{ wordslist[4 * i + 3] }}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<br>
<span id="toggle-advanced" style="cursor: pointer;" onclick="toggleAdvanced()">Advanced &#9654;</span>
<br>
<br>
<div id="advanced-settings" class="hidden" style="width: 400px; margin: auto;">
<label>Encrypt Wallet File: </label>
<input type="password" name="file_password" class="inline" placeholder="password">
<br>
<br>
<label>BIP39 passphrase (optional): </label>
<input type="password" name="passphrase" class="inline" placeholder="passphrase">
<br>
<p style="margin-bottom: 10px;">Addresses range to import: </p>
From: <input type="number" name="range_start" class="inline" placeholder="start" value=0 step=1 min=0 max=100000>
to: <input type="number" name="range_end" class="inline" placeholder="start" value=1000 step=1 min=0 max=100000>
<br>
</div>
</div>
</div>
<button type="submit" id="submit-mnemonic" class="btn centered action" style="width: 140px; max-width: 140px; margin: auto;">Continue</button>
<br><br>
</form>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener("DOMContentLoaded", function(){
{% if existing_device %}
setMnemonicView('import');
{% endif %}
});
function setMnemonicView(view) {
var generatedMnemonic = document.getElementById("generated_mnemonic");
var importMnemonic = document.getElementById("import_mnemonic");
var generateMnemonicBtn = document.getElementById("generate_mnemonic_btn");
var importMnemonicBtn = document.getElementById("import_mnemonic_btn");
var mnemonicValue = document.getElementById("mnemonic_value");
if (view == 'generate') {
generatedMnemonic.style.display = 'block';
importMnemonic.style.display = 'none';
generateMnemonicBtn.className = generateMnemonicBtn.className += ' checked';
importMnemonicBtn.className = importMnemonicBtn.className.replace(/checked/, '');
mnemonicValue.value = '{{ mnemonic }}'
} else {
generatedMnemonic.style.display = 'none';
importMnemonic.style.display = 'block';
generateMnemonicBtn.className = generateMnemonicBtn.className.replace(/checked/, '');
importMnemonicBtn.className = importMnemonicBtn.className += ' checked';
mnemonicValue.value = importMnemonic.value;
importMnemonic.addEventListener("input", function() {
mnemonicValue.value = importMnemonic.value;
});
}
}
async function generateMnemonic() {
let url = `{{ url_for('generatemnemonic') }}`;
var formData = new FormData(document.getElementById('form'));
try {
const response = await fetch(
url,
{
method: 'POST',
body: formData
}
);
if(response.status != 200){
showError(await response.text());
return;
}
const jsonResponse = await response.json();
let mnemonic = jsonResponse.mnemonic;
document.getElementById("mnemonic_value").value = mnemonic;
let wordslist = mnemonic.split(' ');
let mnemonicTable = document.getElementById("mnemonic_table");
mnemonicTable.innerHTML = ``;
for(let i=0; i < wordslist.length / 4; i++) {
mnemonicTable.innerHTML += `
<tr>
<td id="mnemonicwo">${ 4 * i + 1 }. ${ wordslist[4 * i] }</td>
<td>${ 4 * i + 2 }. ${ wordslist[4 * i + 1] }</td>
<td>${ 4 * i + 3 }. ${ wordslist[4 * i + 2] }</td>
<td>${ 4 * i + 4 }. ${ wordslist[4 * i + 3] }</td>
</tr>
`
}
} catch(e) {
console.log("Caught error: ", e);
showError(e);
}
}
function toggleAdvanced() {
let advancedButton = document.getElementById('toggle-advanced');
let advancedSettings = document.getElementById('advanced-settings');
if (advancedSettings.style.display === 'block') {
advancedSettings.style.display = 'none';
advancedButton.innerHTML = 'Advanced &#9654;';
} else {
advancedSettings.style.display = 'block';
advancedButton.innerHTML = 'Advanced &#9660;';
}
}
</script>
{% endblock %}

View file

@ -0,0 +1,49 @@
{% extends "base.jinja" %}
{% block main %}
<h1 style="font-size: 2em;">Add Device</h1><br>
<div id="device_setup_wizard" class="card center" style="width: auto; margin: 40px;">
<div id="device-type-container">
<h1 style="font-size: 1.8em;">Select Your Device Type</h1>
<input id="device-type-searchbar" type="text" placeholder="Filter devices..." style="width: 60%; font-size: 1.4em; padding: 10px;" oninput="filterDeviceTypes(this.value)">
<div id="devices_list" class="row overflow">
{% for cls in specter.device_manager.supported_devices %}
{% if not cls.hot_wallet or specter.rpc %}
<a id="label_device_type_{{ cls.name | replace(' ', '') }}" href="{{ url_for('devices_endpoint.new_device_keys', device_type=cls.device_type) if not cls.hot_wallet else url_for('devices_endpoint.new_device_mnemonic') }}" style="text-decoration: none;">
<div class="small-card radio" id="{{ cls.device_type }}_device_card" style="transform: scale(0.85); margin: 3px;">
<img src="{{ url_for('static', filename='img/devices/' ~ cls.icon) }}" width="18px">
{{ cls.name }}
</div>
</a>
{% endif %}
{% endfor %}
</div>
<a class="note center" style="text-decoration: underline; cursor: pointer;" href="{{ url_for('devices_endpoint.new_device_manual') }}">Manual configuration</a>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function filterDeviceTypes(text) {
let devicesLabels = []
{% for cls in specter.device_manager.supported_devices %}
{% if not cls.hot_wallet or specter.rpc %}
devicesLabels.push('label_device_type_{{ cls.name | replace(' ', '') }}')
{% endif %}
{% endfor %}
if (text) {
for (let deviceLabel of devicesLabels) {
if (deviceLabel.split('label_device_type_')[1].toLowerCase().includes(text.toLowerCase())) {
document.getElementById(deviceLabel).style.display = 'flex';
} else {
document.getElementById(deviceLabel).style.display = 'none';
}
}
} else {
for (let deviceLabel of devicesLabels) {
document.getElementById(deviceLabel).style.display = 'flex';
}
}
}
</script>
{% endblock %}

View file

@ -93,11 +93,7 @@
upub5En4f7k8gaG2KDHvBeEYox...rFpJRHpiZ4DE
</pre>
</div><br>
{% if not device %}
<button type="submit" class="btn centered" name="action" value="newcolddevice" onsubmit="showPacman()">Continue</button>
{% else %}
<button type="submit" class="btn centered" name="action" value="morekeys" onsubmit="showPacman()">Continue</button>
{% endif %}
</div>
{% endif %}
{% if not device or device.hot_wallet %}
@ -169,11 +165,7 @@ m/48h/{{ 0 if specter.info.chain == "main" else 1 }}h/0h/2h</textarea>
<br>
</div>
<br><br>
{% if not device %}
<button type="submit" class="btn centered" name="action" value="newhotdevice">Continue</button>
{% else %}
<button type="submit" class="btn centered" name="action" value="morekeys">Continue</button>
{% endif %}
</div>
{% endif %}
</form>

View file

@ -1,7 +1,7 @@
{#
sidebar_btn - Add button used in the sidebar for wallets and devices.
Parameters:
- url_path: URL path the button should lead to (ie. 'new_wallet', 'new_device').
- url_path: URL path the button should lead to (ie. 'new_wallet', 'new_device_type').
- text: The text of the button.
- id: an optional id for the "a" element
- icon: should show an icon of +

View file

@ -195,7 +195,7 @@
{% for device_name in specter.device_manager.devices_names %}
{{ sidebar_device_list_item(specter.device_manager.devices[device_name], device_alias) }}
{% endfor %}
{{ sidebar_btn(url_for('devices_endpoint.new_device'), 'Add new device','btn_new_device') }}
{{ sidebar_btn(url_for('devices_endpoint.new_device_type'), 'Add new device','btn_new_device') }}
</div>
<br>
<div class="footer">

View file

@ -24,7 +24,7 @@
{% endif %}
</label>
{% endfor %}
<a href="{{ url_for('devices_endpoint.new_device') }}" style="text-decoration: none; color: #fff;">
<a href="{{ url_for('devices_endpoint.new_device_type') }}" style="text-decoration: none; color: #fff;">
<div class="small-card radio">
<img src="{{ url_for('static', filename='img/plus.svg') }}" style="transform: scale(1.5);" class="svg-white">
Add new device

View file

@ -12,7 +12,7 @@ def test_home(caplog, client):
assert result.status_code == 302 # REDIRECT.
result = client.get("/about")
assert b"Welcome to Specter" in result.data
result = client.get("/devices/new_device", follow_redirects=True)
result = client.get("/devices/new_device_type", follow_redirects=True)
assert result.status_code == 200 # OK.
assert b"Add Device" in result.data
result = client.get("/settings", follow_redirects=True)