ui: Add pagination and filters to smsgaddresses page

2024-05-20_merge
tecnovert 2 years ago
parent b5a4df9908
commit 22576c0316
  1. 83
      basicswap/basicswap.py
  2. 91
      basicswap/http_server.py
  3. 86
      basicswap/templates/smsgaddresses.html
  4. 128
      basicswap/ui/page_smsgaddresses.py
  5. 10
      basicswap/ui/util.py
  6. 1
      doc/release-notes.md

@ -6178,17 +6178,35 @@ class BasicSwap(BaseApp):
finally:
self.mxDB.release()
def listAllSMSGAddresses(self, addr_id=None):
filters = ''
if addr_id is not None:
filters += f' WHERE addr_id = {addr_id} '
self.mxDB.acquire()
def listAllSMSGAddresses(self, filters={}):
query_str = 'SELECT addr_id, addr, use_type, active_ind, created_at, note, pubkey FROM smsgaddresses'
query_str += ' WHERE active_ind = 1 '
query_data = {}
if 'addr_id' in filters:
query_str += ' AND addr_id = :addr_id '
query_data['addr_id'] = filters['addr_id']
if 'addressnote' in filters:
query_str += ' AND note LIKE :note '
query_data['note'] = '%' + filters['addressnote'] + '%'
if 'addr_type' in filters and filters['addr_type'] > -1:
query_str += ' AND use_type = :addr_type '
query_data['addr_type'] = filters['addr_type']
sort_dir = filters.get('sort_dir', 'DESC').upper()
sort_by = filters.get('sort_by', 'created_at')
query_str += f' ORDER BY {sort_by} {sort_dir}'
limit = filters.get('limit', None)
if limit is not None:
query_str += f' LIMIT {limit}'
offset = filters.get('offset', None)
if offset is not None:
query_str += f' OFFSET {offset}'
try:
session = scoped_session(self.session_factory)
session = self.openSession()
rv = []
query_str = f'SELECT addr_id, addr, use_type, active_ind, created_at, note, pubkey FROM smsgaddresses {filters} ORDER BY created_at'
q = session.execute(query_str)
q = session.execute(query_str, query_data)
for row in q:
rv.append({
'id': row[0],
@ -6201,9 +6219,27 @@ class BasicSwap(BaseApp):
})
return rv
finally:
session.close()
session.remove()
self.mxDB.release()
self.closeSession(session, commit=False)
def listSmsgAddresses(self, use_type_str):
if use_type_str == 'offer_send_from':
use_type = AddressTypes.OFFER
elif use_type_str == 'offer_send_to':
use_type = AddressTypes.SEND_OFFER
elif use_type_str == 'bid':
use_type = AddressTypes.BID
else:
raise ValueError('Unknown address type')
try:
session = self.openSession()
rv = []
q = session.execute('SELECT sa.addr, ki.label FROM smsgaddresses AS sa LEFT JOIN knownidentities AS ki ON sa.addr = ki.address WHERE sa.use_type = {} AND sa.active_ind = 1 ORDER BY sa.addr_id DESC'.format(use_type))
for row in q:
rv.append((row[0], row[1]))
return rv
finally:
self.closeSession(session, commit=False)
def listAutomationStrategies(self, filters={}):
try:
@ -6341,29 +6377,6 @@ class BasicSwap(BaseApp):
session.remove()
self.mxDB.release()
def listSmsgAddresses(self, use_type_str):
if use_type_str == 'offer_send_from':
use_type = AddressTypes.OFFER
elif use_type_str == 'offer_send_to':
use_type = AddressTypes.SEND_OFFER
elif use_type_str == 'bid':
use_type = AddressTypes.BID
else:
raise ValueError('Unknown address type')
self.mxDB.acquire()
try:
session = scoped_session(self.session_factory)
rv = []
q = session.execute('SELECT sa.addr, ki.label FROM smsgaddresses AS sa LEFT JOIN knownidentities AS ki ON sa.addr = ki.address WHERE sa.use_type = {} AND sa.active_ind = 1 ORDER BY sa.addr_id DESC'.format(use_type))
for row in q:
rv.append((row[0], row[1]))
return rv
finally:
session.close()
session.remove()
self.mxDB.release()
def createCoinALockRefundSwipeTx(self, ci, bid, offer, xmr_swap, xmr_offer):
self.log.debug('Creating %s lock refund swipe tx', ci.coin_name())

@ -17,7 +17,6 @@ from jinja2 import Environment, PackageLoader
from . import __version__
from .util import (
dumpj,
ensure,
toBool,
LockedCoinError,
format_timestamp,
@ -29,7 +28,6 @@ from .chainparams import (
from .basicswap_util import (
strTxState,
strBidState,
strAddressType,
)
from .js_server import (
@ -56,21 +54,12 @@ from .ui.page_wallet import page_wallets, page_wallet
from .ui.page_settings import page_settings
from .ui.page_encryption import page_changepassword, page_unlock, page_lock
from .ui.page_identity import page_identity
from .ui.page_smsgaddresses import page_smsgaddresses
env = Environment(loader=PackageLoader('basicswap', 'templates'))
env.filters['formatts'] = format_timestamp
def validateTextInput(text, name, messages, max_length=None):
if max_length is not None and len(text) > max_length:
messages.append(f'Error: {name} is too long')
return False
if len(text) > 0 and all(c.isalnum() or c.isspace() for c in text) is False:
messages.append(f'Error: {name} must consist of only letters and digits')
return False
return True
def extractDomain(url):
return url.split('://', 1)[1].split('/', 1)[0]
@ -396,82 +385,6 @@ class HttpHandler(BaseHTTPRequestHandler):
'summary': summary,
})
def page_smsgaddresses(self, url_split, post_string):
swap_client = self.server.swap_client
swap_client.checkSystemStatus()
summary = swap_client.getSummary()
page_data = {}
messages = []
err_messages = []
smsgaddresses = []
listaddresses = True
form_data = self.checkForm(post_string, 'smsgaddresses', err_messages)
if form_data:
edit_address_id = None
for key in form_data:
if key.startswith(b'editaddr_'):
edit_address_id = int(key.split(b'_')[1])
break
if edit_address_id is not None:
listaddresses = False
page_data['edit_address'] = edit_address_id
page_data['addr_data'] = swap_client.listAllSMSGAddresses(addr_id=edit_address_id)[0]
elif b'saveaddr' in form_data:
edit_address_id = int(form_data[b'edit_address_id'][0].decode('utf-8'))
edit_addr = form_data[b'edit_address'][0].decode('utf-8')
active_ind = int(form_data[b'active_ind'][0].decode('utf-8'))
ensure(active_ind in (0, 1), 'Invalid sort by')
addressnote = '' if b'addressnote' not in form_data else form_data[b'addressnote'][0].decode('utf-8')
if not validateTextInput(addressnote, 'Address note', messages, max_length=30):
listaddresses = False
page_data['edit_address'] = edit_address_id
else:
swap_client.editSMSGAddress(edit_addr, active_ind=active_ind, addressnote=addressnote)
messages.append(f'Edited address {edit_addr}')
elif b'shownewaddr' in form_data:
listaddresses = False
page_data['new_address'] = True
elif b'showaddaddr' in form_data:
listaddresses = False
page_data['new_send_address'] = True
elif b'createnewaddr' in form_data:
addressnote = '' if b'addressnote' not in form_data else form_data[b'addressnote'][0].decode('utf-8')
if not validateTextInput(addressnote, 'Address note', messages, max_length=30):
listaddresses = False
page_data['new_address'] = True
else:
new_addr, pubkey = swap_client.newSMSGAddress(addressnote=addressnote)
messages.append(f'Created address {new_addr}, pubkey {pubkey}')
elif b'createnewsendaddr' in form_data:
pubkey_hex = form_data[b'addresspubkey'][0].decode('utf-8')
addressnote = '' if b'addressnote' not in form_data else form_data[b'addressnote'][0].decode('utf-8')
if not validateTextInput(addressnote, 'Address note', messages, max_length=30) or \
not validateTextInput(pubkey_hex, 'Pubkey', messages, max_length=66):
listaddresses = False
page_data['new_send_address'] = True
else:
new_addr = swap_client.addSMSGAddress(pubkey_hex, addressnote=addressnote)
messages.append(f'Added address {new_addr}')
if listaddresses is True:
smsgaddresses = swap_client.listAllSMSGAddresses()
network_addr = swap_client.network_addr
for addr in smsgaddresses:
addr['type'] = strAddressType(addr['type'])
template = env.get_template('smsgaddresses.html')
return self.render_template(template, {
'messages': messages,
'err_messages': err_messages,
'data': page_data,
'smsgaddresses': smsgaddresses,
'network_addr': network_addr,
'summary': summary,
})
def page_shutdown(self, url_split, post_string):
swap_client = self.server.swap_client
@ -611,7 +524,7 @@ class HttpHandler(BaseHTTPRequestHandler):
if page == 'watched':
return self.page_watched(url_split, post_string)
if page == 'smsgaddresses':
return self.page_smsgaddresses(url_split, post_string)
return page_smsgaddresses(self, url_split, post_string)
if page == 'identity':
return page_identity(self, url_split, post_string)
if page == 'tor':

@ -97,7 +97,7 @@
<div class="w-full md:w-0/12">
<div class="flex flex-wrap justify-end -m-1.5">
<div class="w-full md:w-auto p-1.5 ml-2">
<button name="saveaddr" value="Save Address" type="submit" class="flex flex-wrap justify-center w-full px-4 py-2.5 bg-blue-500 hover:bg-blue-600 font-medium text-sm text-white border border-blue-500 rounded-md shadow-button focus:ring-0 focus:outline-none "><svg class="text-gray-500 w-5 h-5 mr-2" xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><g stroke-linecap="square" stroke-width="2" fill="none" stroke="#ffffff" stroke-linejoin="miter" class="nc-icon-wrapper" stroke-miterlimit="10"><line x1="12" y1="7" x2="12" y2="17" stroke="#ffffff"></line> <line x1="17" y1="12" x2="7" y2="12" stroke="#ffffff"></line> <circle cx="12" cy="12" r="11"></circle></g></svg>Create Address</button>
<button name="saveaddr" value="Save Address" type="submit" class="flex flex-wrap justify-center w-full px-4 py-2.5 bg-blue-500 hover:bg-blue-600 font-medium text-sm text-white border border-blue-500 rounded-md shadow-button focus:ring-0 focus:outline-none "><svg class="text-gray-500 w-5 h-5 mr-2" xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><g stroke-linecap="square" stroke-width="2" fill="none" stroke="#ffffff" stroke-linejoin="miter" class="nc-icon-wrapper" stroke-miterlimit="10"><line x1="12" y1="7" x2="12" y2="17" stroke="#ffffff"></line> <line x1="17" y1="12" x2="7" y2="12" stroke="#ffffff"></line> <circle cx="12" cy="12" r="11"></circle></g></svg>Save Address</button>
</div>
<div class="w-full md:w-auto p-1.5 ml-2">
<button name="cancel" value="Cancel" type="submit" class="flex flex-wrap justify-center w-full px-4 py-2.5 font-medium text-sm text-coolGray-500 hover:text-coolGray-600 border border-coolGray-200 hover:border-coolGray-300 bg-white rounded-md shadow-button focus:ring-0 focus:outline-none"><svg class="text-gray-500 w-5 h-5 mr-2" height="24" width="24" viewBox="0 0 24 24"><g stroke-linecap="square" stroke-width="2" fill="none" stroke="#556987" stroke-linejoin="miter" class="nc-icon-wrapper" stroke-miterlimit="10"><line data-cap="butt" x1="18" y1="12" x2="7" y2="12" stroke-linecap="butt" stroke="#556987"></line> <polyline points=" 11,16 7,12 11,8 " stroke="#5569878"></polyline> <circle cx="12" cy="12" r="11"></circle></g></svg>Go Back</button>
@ -186,6 +186,85 @@
</div>
</div>
{% else %}
<div class="container px-0 mx-auto mt-5">
<div class="overflow-x-auto relative border sm:rounded-lg">
<table class="w-full text-sm text-left text-gray-500 outline-none border-gray-300">
<thead class="text-xs text-gray-700 border-b uppercase bg-gray-50 outline-none border-gray-300">
<tr>
<th scope="col" class="py-3 px-6">Filter</th>
<th scope="col" class="py-3 px-6"></th>
<th scope="col"></th>
</tr>
</thead>
<tr class="bg-white border-t hover:bg-gray-50">
<td class="py-4 px-6 bold w-96"> Sort by: </td>
<td class="py-4 bold w-96">
<select class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg outline-none focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" name="sort_by">
<option value="created_at" {% if filters.sort_by=='created_at' %} selected{% endif %}>Created At</option>
</select>
</td>
<td class="py-4 px-6 pr-5">
<select class="pr-15 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg outline-none focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" name="sort_dir">
<option value="asc" {% if filters.sort_dir=='asc' %} selected{% endif %}>Ascending</option>
<option value="desc" {% if filters.sort_dir=='desc' %} selected{% endif %}>Descending</option>
</select>
</td>
</tr>
<tr class="bg-white border-t hover:bg-gray-50">
<td class="py-4 px-6 bold w-96">Note</td>
<td class="py-4 pr-5">
<input class="appearance-none bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg outline-none focus:ring-blue-500 focus:border-blue-500 block p-2.5 w-full" name="filter_addressnote" type="text" value="{{ filters.addressnote }}" maxlength="30"> </td>
</tr>
<tr class="bg-white border-t hover:bg-gray-50">
<td class="py-4 px-6 bold w-96">Type</td>
<td class="py-4 pr-5">
<select class="appearance-none bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg outline-none focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" name="filter_addr_type">
<option{% if filters.addr_type=="-1" %} selected{% endif %} value="-1">Any</option>
{% for a in page_data.addr_types %}
<option{% if filters.addr_type==a[0] %} selected{% endif %} value="{{ a[0] }}">{{ a[1] }}</option>
{% endfor %}
</select>
</tr>
</table>
</div>
</div>
<div class="pt-10 bg-white bg-opacity-60 rounded-b-md">
<div class="w-full md:w-0/12">
<div class="flex flex-wrap justify-end -m-1.5">
<div class="w-full md:w-auto p-1.5">
<button type="submit" name='pageback' value="Page Back" class="outline-none flex flex-wrap justify-center w-full px-4 py-2.5 font-medium text-sm text-coolGray-500 hover:text-coolGray-600 border border-coolGray-200 hover:border-coolGray-300 bg-white rounded-md shadow-button focus:ring-0 focus:outline-none">
<svg aria-hidden="true" class="mr-2 w-5 h-5" fill="#556987" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z" clip-rule="evenodd"></path>
</svg> <span>Page Back</span> </button>
</div>
<div class="flex items-center">
<div class="w-full md:w-auto p-1.5">
<p class="text-sm font-heading">Page: {{ filters.page_no }}</p>
</div>
</div>
<div class="w-full md:w-auto p-1.5">
<button type="submit" name='pageforwards' value="Page Forwards" class="outline-none flex flex-wrap justify-center w-full px-4 py-2.5 font-medium text-sm text-coolGray-500 hover:text-coolGray-600 border border-coolGray-200 hover:border-coolGray-300 bg-white rounded-md shadow-button focus:ring-0 focus:outline-none"> <span>Page Forwards</span>
<svg aria-hidden="true" class="ml-2 w-5 h-5" fill="#556987" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
</button>
</div>
<div class="w-full md:w-auto p-1.5 ml-2">
<button name='clearfilters' value="Clear Filters" class="flex flex-wrap justify-center w-full px-4 py-2.5 font-medium text-sm text-coolGray-500 hover:text-coolGray-600 border border-coolGray-200 hover:border-coolGray-300 bg-white rounded-md shadow-button focus:ring-0 focus:outline-none"> <svg class="mr-2 w-5 h-5" xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24">
<g stroke-linecap="round" stroke-width="2" fill="none" stroke="#556987" stroke-linejoin="round" class="nc-icon-wrapper">
<rect x="2" y="2" width="7" height="7"></rect>
<rect x="15" y="15" width="7" height="7"></rect>
<rect x="2" y="15" width="7" height="7"></rect>
<polyline points="15 6 17 8 22 3" stroke="#556987"></polyline>
</g>
</svg> Clear Filters</button>
</div>
<div class="w-full md:w-auto p-1.5 ml-2">
<button name="" value="Submit" type="submit" class="flex flex-wrap justify-center w-full px-4 py-2.5 bg-blue-500 hover:bg-blue-600 font-medium text-sm text-white border border-blue-500 rounded-md shadow-button focus:ring-0 focus:outline-none"><svg class="text-gray-500 w-5 h-5 mr-2" xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><g stroke-linecap="round" stroke-width="2" fill="none" stroke="#ffffff" stroke-linejoin="round" ><polyline points=" 6,12 10,16 18,8 " stroke="#ffffff"></polyline> <circle cx="12" cy="12" r="11"></circle></g></svg> Apply</button>
</div>
</div>
</div>
</div>
<div class="container px-0 mx-auto mt-5">
<div class="overflow-x-auto relative border sm:rounded-lg">
<table class="w-full text-sm text-left text-gray-500 outline-none border-gray-300">
@ -200,7 +279,7 @@
</tr>
</thead>
<tr class="bg-white border-t hover:bg-gray-50">
<td class="py-6 px-6"><b class="monospace">{{ network_addr }}</b></td>
<td class="py-6 px-6"><b class="monospace">{{ page_data.network_addr }}</b></td>
<td class="py-4">Network Address
<td />
<td class="py-4">
@ -233,6 +312,7 @@
</div>
</div>
</div>
<input type="hidden" name="pageno" value="{{ filters.page_no }}">
{% endif %}
<input type="hidden" name="formid" value="{{ form_id }}"></form>
</div>
@ -244,4 +324,4 @@
{% include 'footer.html' %}
</div>
</body>
</html>
</html>

@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2023 tecnovert
# Distributed under the MIT software license, see the accompanying
# file LICENSE or http://www.opensource.org/licenses/mit-license.php.
from .util import (
PAGE_LIMIT,
get_data_entry,
have_data_entry,
get_data_entry_or,
validateTextInput,
set_pagination_filters,
)
from basicswap.util import (
ensure,
)
from basicswap.basicswap_util import (
AddressTypes,
strAddressType,
)
def page_smsgaddresses(self, url_split, post_string):
swap_client = self.server.swap_client
swap_client.checkSystemStatus()
summary = swap_client.getSummary()
filters = {
'page_no': 1,
'limit': PAGE_LIMIT,
'sort_by': 'created_at',
'sort_dir': 'desc',
'addr_type': -1,
}
page_data = {}
messages = []
err_messages = []
smsgaddresses = []
listaddresses = True
form_data = self.checkForm(post_string, 'smsgaddresses', err_messages)
if form_data:
edit_address_id = None
for key in form_data:
if key.startswith(b'editaddr_'):
edit_address_id = int(key.split(b'_')[1])
break
if edit_address_id is not None:
listaddresses = False
page_data['edit_address'] = edit_address_id
page_data['addr_data'] = swap_client.listAllSMSGAddresses({'addr_id': edit_address_id})[0]
elif have_data_entry(form_data, 'saveaddr'):
edit_address_id = int(get_data_entry(form_data, 'edit_address_id'))
edit_addr = get_data_entry(form_data, 'edit_address')
active_ind = int(get_data_entry(form_data, 'active_ind'))
ensure(active_ind in (0, 1), 'Invalid sort by')
addressnote = get_data_entry_or(form_data, 'addressnote', '')
if not validateTextInput(addressnote, 'Address note', err_messages, max_length=30):
listaddresses = False
page_data['edit_address'] = edit_address_id
else:
swap_client.editSMSGAddress(edit_addr, active_ind=active_ind, addressnote=addressnote)
messages.append(f'Edited address {edit_addr}')
elif have_data_entry(form_data, 'shownewaddr'):
listaddresses = False
page_data['new_address'] = True
elif have_data_entry(form_data, 'showaddaddr'):
listaddresses = False
page_data['new_send_address'] = True
elif have_data_entry(form_data, 'createnewaddr'):
addressnote = get_data_entry_or(form_data, 'addressnote', '')
if not validateTextInput(addressnote, 'Address note', err_messages, max_length=30):
listaddresses = False
page_data['new_address'] = True
else:
new_addr, pubkey = swap_client.newSMSGAddress(addressnote=addressnote)
messages.append(f'Created address {new_addr}, pubkey {pubkey}')
elif have_data_entry(form_data, 'createnewsendaddr'):
pubkey_hex = get_data_entry(form_data, 'addresspubkey')
addressnote = get_data_entry_or(form_data, 'addressnote', '')
if not validateTextInput(addressnote, 'Address note', messages, max_length=30) or \
not validateTextInput(pubkey_hex, 'Pubkey', messages, max_length=66):
listaddresses = False
page_data['new_send_address'] = True
else:
new_addr = swap_client.addSMSGAddress(pubkey_hex, addressnote=addressnote)
messages.append(f'Added address {new_addr}')
if not have_data_entry(form_data, 'clearfilters'):
if have_data_entry(form_data, 'sort_by'):
sort_by = get_data_entry(form_data, 'sort_by')
ensure(sort_by in ['created_at', 'rate'], 'Invalid sort by')
filters['sort_by'] = sort_by
if have_data_entry(form_data, 'sort_dir'):
sort_dir = get_data_entry(form_data, 'sort_dir')
ensure(sort_dir in ['asc', 'desc'], 'Invalid sort dir')
filters['sort_dir'] = sort_dir
if have_data_entry(form_data, 'filter_addressnote'):
addressnote = get_data_entry(form_data, 'filter_addressnote')
if validateTextInput(addressnote, 'Address note', err_messages, max_length=30):
filters['addressnote'] = addressnote
if have_data_entry(form_data, 'filter_addr_type'):
filters['addr_type'] = int(get_data_entry(form_data, 'filter_addr_type'))
set_pagination_filters(form_data, filters)
if listaddresses is True:
smsgaddresses = swap_client.listAllSMSGAddresses(filters)
page_data['addr_types'] = [(int(t), strAddressType(t)) for t in AddressTypes]
page_data['network_addr'] = swap_client.network_addr
for addr in smsgaddresses:
addr['type'] = strAddressType(addr['type'])
template = self.server.env.get_template('smsgaddresses.html')
return self.render_template(template, {
'messages': messages,
'err_messages': err_messages,
'filters': filters,
'data': page_data,
'smsgaddresses': smsgaddresses,
'page_data': page_data,
'summary': summary,
})

@ -446,3 +446,13 @@ def checkAddressesOwned(swap_client, ci, wallet_info):
wallet_info['deposit_address'] = 'Error: unowned address'
elif swap_client._restrict_unknown_seed_wallets and not ci.knownWalletSeed():
wallet_info['deposit_address'] = 'WARNING: Unknown wallet seed'
def validateTextInput(text, name, messages, max_length=None):
if max_length is not None and len(text) > max_length:
messages.append(f'Error: {name} is too long')
return False
if len(text) > 0 and all(c.isalnum() or c.isspace() for c in text) is False:
messages.append(f'Error: {name} must consist of only letters and digits')
return False
return True

@ -7,6 +7,7 @@
- Accepted bids will timeout if the peer does not respond within an hour after the bid expires.
- Ensure messages are always sent from and to the expected addresses.
- ui: Add pagination and filters to smsgaddresses page
0.0.59

Loading…
Cancel
Save