mirror of
https://github.com/ziirish/burp-ui.git
synced 2026-05-21 06:45:24 -06:00
fix: better CRUD API + new method to retrieve clients
This commit is contained in:
parent
62a56af76b
commit
3de063a036
12 changed files with 163 additions and 90 deletions
|
|
@ -127,6 +127,7 @@ class BUIAgent(BUIlogging, Dummy):
|
|||
'store_conf_srv': self.backend.store_conf_srv,
|
||||
'expand_path': self.backend.expand_path,
|
||||
'delete_client': self.backend.delete_client,
|
||||
'clients_list': self.backend.clients_list,
|
||||
'get_parser_attr': self.backend.get_parser_attr
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -92,10 +92,10 @@ class ClientTree(Resource):
|
|||
return jsonify(results=j)
|
||||
|
||||
|
||||
@api.resource('/api/client-stat.json/<name>',
|
||||
'/api/<server>/client-stat.json/<name>',
|
||||
'/api/client-stat.json/<name>/<int:backup>',
|
||||
'/api/<server>/client-stat.json/<name>/<int:backup>',
|
||||
@api.resource('/api/client-stats.json/<name>',
|
||||
'/api/<server>/client-stats.json/<name>',
|
||||
'/api/client-stats.json/<name>/<int:backup>',
|
||||
'/api/<server>/client-stats.json/<name>/<int:backup>',
|
||||
endpoint='api.client_stats')
|
||||
class ClientStats(Resource):
|
||||
"""The :class:`burpui.api.client.ClientStats` resource allows you to
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class ServersStats(Resource):
|
|||
@login_required
|
||||
def get(self):
|
||||
r = []
|
||||
if hasattr(api.bui.cli, 'servers'):
|
||||
if hasattr(api.bui.cli, 'servers'): # pragma: no cover
|
||||
check = False
|
||||
allowed = []
|
||||
if (api.bui.acl and not
|
||||
|
|
@ -73,7 +73,7 @@ class Live(Resource):
|
|||
l = (api.bui.cli.is_one_backup_running(server))[server]
|
||||
else:
|
||||
l = api.bui.cli.is_one_backup_running()
|
||||
if isinstance(l, dict):
|
||||
if isinstance(l, dict): # pragma: no cover
|
||||
for (k, a) in iteritems(l):
|
||||
for c in a:
|
||||
s = {}
|
||||
|
|
@ -84,7 +84,7 @@ class Live(Resource):
|
|||
except BUIserverException:
|
||||
s['status'] = []
|
||||
r.append(s)
|
||||
else:
|
||||
else: # pragma: no cover
|
||||
for c in l:
|
||||
s = {}
|
||||
s['client'] = c
|
||||
|
|
|
|||
|
|
@ -13,16 +13,17 @@ from burpui.api import api
|
|||
from flask.ext.restful import reqparse, abort, Resource
|
||||
from flask.ext.login import current_user, login_required
|
||||
from flask import jsonify, flash, request, redirect, url_for
|
||||
from werkzeug.datastructures import ImmutableMultiDict
|
||||
if sys.version_info >= (3, 0):
|
||||
from urllib.parse import unquote
|
||||
else:
|
||||
from urllib import unquote
|
||||
|
||||
|
||||
@api.resource('/api/server-config',
|
||||
'/api/<server>/server-config',
|
||||
'/api/server-config/<path:conf>',
|
||||
'/api/<server>/server-config/<path:conf>',
|
||||
@api.resource('/api/settings/server-config',
|
||||
'/api/<server>/settings/server-config',
|
||||
'/api/settings/server-config/<path:conf>',
|
||||
'/api/<server>/settings/server-config/<path:conf>',
|
||||
endpoint='api.server_settings')
|
||||
class ServerSettings(Resource):
|
||||
"""The :class:`burpui.api.settings.ServerSettings` resource allows you to
|
||||
|
|
@ -31,6 +32,11 @@ class ServerSettings(Resource):
|
|||
This resource is part of the :mod:`burpui.api.settings` module.
|
||||
"""
|
||||
|
||||
@login_required
|
||||
def post(self, conf=None, server=None):
|
||||
noti = api.bui.cli.store_conf_srv(request.form, conf, server)
|
||||
return jsonify(notif=noti)
|
||||
|
||||
@login_required
|
||||
def get(self, conf=None, server=None):
|
||||
"""**GET** method provided by the webservice.
|
||||
|
|
@ -182,13 +188,29 @@ class ServerSettings(Resource):
|
|||
defaults=api.bui.cli.get_parser_attr('defaults', server))
|
||||
|
||||
|
||||
@api.resource('/api/<client>/client-config',
|
||||
'/api/<client>/client-config/<path:conf>',
|
||||
'/api/<server>/<client>/client-config',
|
||||
'/api/<server>/<client>/client-config/<path:conf>',
|
||||
@api.resource('/api/settings/clients.json',
|
||||
'/api/<server>/settings/clients.json',
|
||||
endpoint='api.clients_list')
|
||||
class ClientsList(Resource):
|
||||
|
||||
@login_required
|
||||
def get(self, server=None):
|
||||
res = api.bui.cli.clients_list(server)
|
||||
return jsonify(result=res)
|
||||
|
||||
|
||||
@api.resource('/api/settings/<client>/client-config',
|
||||
'/api/settings/<client>/client-config/<path:conf>',
|
||||
'/api/<server>/settings/<client>/client-config',
|
||||
'/api/<server>/settings/<client>/client-config/<path:conf>',
|
||||
endpoint='api.client_settings')
|
||||
class ClientSettings(Resource):
|
||||
|
||||
@login_required
|
||||
def post(self, server=None, client=None, conf=None):
|
||||
noti = api.bui.cli.store_conf_cli(request.form, client, conf, server)
|
||||
return jsonify(notif=noti)
|
||||
|
||||
@login_required
|
||||
def get(self, server=None, client=None, conf=None):
|
||||
# Only the admin can edit the configuration
|
||||
|
|
@ -212,8 +234,8 @@ class ClientSettings(Resource):
|
|||
defaults=api.bui.cli.get_parser_attr('defaults', server))
|
||||
|
||||
|
||||
@api.resource('/api/new-client',
|
||||
'/api/<server>/new-client',
|
||||
@api.resource('/api/settings/new-client',
|
||||
'/api/<server>/settings/new-client',
|
||||
endpoint='api.new_client')
|
||||
class NewClient(Resource):
|
||||
|
||||
|
|
@ -222,7 +244,7 @@ class NewClient(Resource):
|
|||
self.parser.add_argument('newclient', type=str)
|
||||
|
||||
@login_required
|
||||
def post(self, server=None):
|
||||
def put(self, server=None):
|
||||
# Only the admin can edit the configuration
|
||||
if (api.bui.acl and not
|
||||
api.bui.acl.is_admin(current_user.get_id())):
|
||||
|
|
@ -230,19 +252,23 @@ class NewClient(Resource):
|
|||
|
||||
newclient = self.parser.parse_args()['newclient']
|
||||
if not newclient:
|
||||
flash('No client name provided', 'danger')
|
||||
return redirect(request.referrer)
|
||||
abort(500, message='No client name provided')
|
||||
# clientconfdir = api.bui.cli.get_parser_attr('clientconfdir', server)
|
||||
# if not clientconfdir:
|
||||
# flash('Could not proceed, no \'clientconfdir\' find', 'warning')
|
||||
# return redirect(request.referrer)
|
||||
return redirect(url_for('view.cli_settings', server=server, client=newclient))
|
||||
noti = api.bui.cli.store_conf_cli(ImmutableMultiDict(), newclient, None, server)
|
||||
if server:
|
||||
noti.append([3, '<a href="{}">Click here</a> to edit \'{}\' configuration'.format(url_for('view.cli_settings', server=server, client=newclient), newclient)])
|
||||
else:
|
||||
noti.append([3, '<a href="{}">Click here</a> to edit \'{}\' configuration'.format(url_for('view.cli_settings', client=newclient), newclient)])
|
||||
return {'notif': noti}, 201
|
||||
|
||||
|
||||
@api.resource('/api/path-expander',
|
||||
'/api/<server>/path-expander',
|
||||
'/api/path-expander/<client>',
|
||||
'/api/<server>/path-expander/<client>',
|
||||
@api.resource('/api/settings/path-expander',
|
||||
'/api/<server>/settings/path-expander',
|
||||
'/api/settings/path-expander/<client>',
|
||||
'/api/<server>/settings/path-expander/<client>',
|
||||
endpoint='api.path_expander')
|
||||
class PathExpander(Resource):
|
||||
|
||||
|
|
@ -251,7 +277,7 @@ class PathExpander(Resource):
|
|||
self.parser.add_argument('path')
|
||||
|
||||
@login_required
|
||||
def post(self, server=None, client=None):
|
||||
def get(self, server=None, client=None):
|
||||
# Only the admin can edit the configuration
|
||||
if (api.bui.acl and not
|
||||
api.bui.acl.is_admin(current_user.get_id())):
|
||||
|
|
@ -266,19 +292,19 @@ class PathExpander(Resource):
|
|||
return jsonify(result=paths)
|
||||
|
||||
|
||||
@api.resource('/api/delete-client',
|
||||
'/api/<server>/delete-client',
|
||||
'/api/delete-client/<client>',
|
||||
'/api/<server>/delete-client/<client>',
|
||||
@api.resource('/api/settings/delete-client',
|
||||
'/api/<server>/settings/delete-client',
|
||||
'/api/settings/delete-client/<client>',
|
||||
'/api/<server>/settings/delete-client/<client>',
|
||||
endpoint='api.delete_client')
|
||||
class DeleteClient(Resource):
|
||||
|
||||
@login_required
|
||||
def post(self, server=None, client=None):
|
||||
def delete(self, server=None, client=None):
|
||||
# Only the admin can edit the configuration
|
||||
if (api.bui.acl and not
|
||||
api.bui.acl.is_admin(current_user.get_id())):
|
||||
noti = [2, 'Sorry, you don\'t have rights to access the setting panel']
|
||||
return jsonify(notif=noti)
|
||||
|
||||
return jsonify(notif=api.bui.cli.delete_client(client, server))
|
||||
return {'notif': api.bui.cli.delete_client(client, server)}, 200
|
||||
|
|
|
|||
|
|
@ -551,6 +551,15 @@ class BUIbackend(BUIlogging):
|
|||
"""
|
||||
raise NotImplementedError("Sorry, the current Backend does not implement this method!")
|
||||
|
||||
def clients_list(self, agent=None):
|
||||
"""The :func:`burpui.misc.backend.interface.BUIbackend.clients_list`
|
||||
function is used to retrieve a list of clients with their configuration
|
||||
file.
|
||||
|
||||
:returns: A list of clients with their configuration file
|
||||
"""
|
||||
raise NotImplementedError("Sorry, the current Backend does not implement this method!")
|
||||
|
||||
def delete_client(self, client=None, agent=None):
|
||||
"""The :func:`burpui.misc.backend.interface.BUIbackend.delete_client`
|
||||
function is used to delete a client from burp's configuration.
|
||||
|
|
|
|||
|
|
@ -184,6 +184,10 @@ class Burp(BUIbackend):
|
|||
"""See :func:`burpui.misc.backend.interface.BUIbackend.delete_client`"""
|
||||
return self.servers[agent].delete_client(client)
|
||||
|
||||
def clients_list(self, agent=None):
|
||||
"""See :func:`burpui.misc.backend.interface.BUIbackend.clients_list`"""
|
||||
return self.servers[agent].clients_list()
|
||||
|
||||
def get_parser_attr(self, attr=None, agent=None):
|
||||
"""See :func:`burpui.misc.backend.interface.BUIbackend.get_parser_attr`"""
|
||||
return self.servers[agent].get_parser_attr(attr)
|
||||
|
|
@ -425,6 +429,11 @@ class NClient(BUIbackend):
|
|||
data = {'func': 'delete_client', 'args': {'client': client}}
|
||||
return json.loads(self.do_command(data))
|
||||
|
||||
def clients_list(self, agent=None):
|
||||
"""See :func:`burpui.misc.backend.interface.BUIbackend.clients_list`"""
|
||||
data = {'func': 'clients_list', 'args': None}
|
||||
return json.loads(self.do_command(data))
|
||||
|
||||
def get_parser_attr(self, attr=None, agent=None):
|
||||
"""See :func:`burpui.misc.backend.interface.BUIbackend.get_parser_attr`"""
|
||||
data = {'func': 'get_parser_attr', 'args': {'attr': attr}}
|
||||
|
|
|
|||
|
|
@ -692,6 +692,7 @@ class Parser(BUIparser):
|
|||
def _list_clients(self):
|
||||
if not self.clientconfdir:
|
||||
return []
|
||||
|
||||
res = []
|
||||
for f in os.listdir(self.clientconfdir):
|
||||
ff = os.path.join(self.clientconfdir, f)
|
||||
|
|
@ -700,6 +701,14 @@ class Parser(BUIparser):
|
|||
|
||||
return res
|
||||
|
||||
def list_clients(self):
|
||||
"""See :func:`burpui.misc.parser.interface.BUIparser.list_clients`"""
|
||||
self.read_server_conf()
|
||||
if not self.clientconfdir:
|
||||
return []
|
||||
|
||||
return self._list_clients()
|
||||
|
||||
def store_client_conf(self, data, client=None, conf=None):
|
||||
"""See :func:`burpui.misc.parser.interface.BUIparser.store_client_conf`"""
|
||||
if conf and not conf.startswith('/'):
|
||||
|
|
|
|||
|
|
@ -179,6 +179,14 @@ class BUIparser(BUIlogging):
|
|||
"""
|
||||
raise NotImplementedError("Sorry, the current Parser does not implement this method!")
|
||||
|
||||
def list_clients(self):
|
||||
""":func:`burpui.misc.parser.interface.BUIparser.list_clients` is used
|
||||
to retrieve a list of clients with their configuration file.
|
||||
|
||||
:returns: A list of clients with their configuration file
|
||||
"""
|
||||
raise NotImplementedError("Sorry, the current Parser does not implement this method!")
|
||||
|
||||
def remove_client(self, client=None):
|
||||
""":func:`burpui.misc.parser.interface.BUIparser.remove_client` is used
|
||||
to delete a client from burp's configuration.
|
||||
|
|
|
|||
|
|
@ -56,10 +56,10 @@ And here is the main site
|
|||
"""
|
||||
|
||||
|
||||
@view.route('/settings', methods=['GET', 'POST'])
|
||||
@view.route('/settings/<path:conf>', methods=['GET', 'POST'])
|
||||
@view.route('/<server>/settings', methods=['GET', 'POST'])
|
||||
@view.route('/<server>/settings/<path:conf>', methods=['GET', 'POST'])
|
||||
@view.route('/settings')
|
||||
@view.route('/settings/<path:conf>')
|
||||
@view.route('/<server>/settings')
|
||||
@view.route('/<server>/settings/<path:conf>')
|
||||
@login_required
|
||||
def settings(server=None, conf=None):
|
||||
# Only the admin can edit the configuration
|
||||
|
|
@ -72,18 +72,15 @@ def settings(server=None, conf=None):
|
|||
pass
|
||||
if not server:
|
||||
server = request.args.get('server')
|
||||
if request.method == 'POST':
|
||||
noti = view.bui.cli.store_conf_srv(request.form, conf, server)
|
||||
return jsonify(notif=noti)
|
||||
return render_template('settings.html', settings=True, server=server, conf=conf)
|
||||
|
||||
|
||||
@view.route('/client/client-settings', methods=['GET', 'POST'])
|
||||
@view.route('/<client>/client-settings', methods=['GET', 'POST'])
|
||||
@view.route('/<client>/client-settings/<path:conf>', methods=['GET', 'POST'])
|
||||
@view.route('/<server>/client/client-settings', methods=['GET', 'POST'])
|
||||
@view.route('/<server>/<client>/client-settings', methods=['GET', 'POST'])
|
||||
@view.route('/<server>/<client>/client-settings/<path:conf>', methods=['GET', 'POST'])
|
||||
@view.route('/client/client-settings')
|
||||
@view.route('/<client>/client-settings')
|
||||
@view.route('/<client>/client-settings/<path:conf>')
|
||||
@view.route('/<server>/client/client-settings')
|
||||
@view.route('/<server>/<client>/client-settings')
|
||||
@view.route('/<server>/<client>/client-settings/<path:conf>')
|
||||
@login_required
|
||||
def cli_settings(server=None, client=None, conf=None):
|
||||
# Only the admin can edit the configuration
|
||||
|
|
@ -98,9 +95,6 @@ def cli_settings(server=None, client=None, conf=None):
|
|||
client = request.args.get('client')
|
||||
if not server:
|
||||
server = request.args.get('server')
|
||||
if request.method == 'POST':
|
||||
noti = view.bui.cli.store_conf_cli(request.form, client, conf, server)
|
||||
return jsonify(notif=noti)
|
||||
return render_template('settings.html', settings=True, client=client, server=server, conf=conf)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -193,20 +193,11 @@ app.controller('ConfigCtrl', function($scope, $http) {
|
|||
submit.attr('disabled', true);
|
||||
/* submit the data */
|
||||
$.ajax({
|
||||
url: form.attr('action'),
|
||||
type: form.attr('method'),
|
||||
data: form.serialize()
|
||||
url: form.attr('action'),
|
||||
type: 'POST',
|
||||
data: form.serialize()
|
||||
}).fail(function(xhr, stat, err) {
|
||||
/* display errors if something went wrong HTTP side */
|
||||
var msg = '<strong>ERROR:</strong> ';
|
||||
if (stat && err) {
|
||||
msg += '<p>'+stat+'</p><pre>'+err+'</pre>';
|
||||
} else if (stat) {
|
||||
msg += '<p>'+stat+'</p>';
|
||||
} else if (err) {
|
||||
msg += '<pre>'+err+'</pre>';
|
||||
}
|
||||
notif(2, msg, 10000);
|
||||
$scope.showError(stat, err);
|
||||
}).done(function(data) {
|
||||
/* The server answered correctly but some errors may have occurred server
|
||||
* side so we display them */
|
||||
|
|
@ -218,6 +209,7 @@ app.controller('ConfigCtrl', function($scope, $http) {
|
|||
}
|
||||
$scope.setSettings.$setPristine();
|
||||
$scope.changed = false;
|
||||
$scope.getClientsList();
|
||||
}).always(function() {
|
||||
/* reset the submit button state */
|
||||
submit.text(sav);
|
||||
|
|
@ -229,6 +221,18 @@ app.controller('ConfigCtrl', function($scope, $http) {
|
|||
});
|
||||
}
|
||||
};
|
||||
$scope.showError = function(stat, err) {
|
||||
/* display errors if something went wrong HTTP side */
|
||||
var msg = '<strong>ERROR:</strong> ';
|
||||
if (stat && err) {
|
||||
msg += '<p>'+stat+'</p><pre>'+err+'</pre>';
|
||||
} else if (stat) {
|
||||
msg += '<p>'+stat+'</p>';
|
||||
} else if (err) {
|
||||
msg += '<pre>'+err+'</pre>';
|
||||
}
|
||||
notif(2, msg, 10000);
|
||||
};
|
||||
$scope.remove = function(key, index) {
|
||||
if (!$scope.old[key]) {
|
||||
$scope.old[key] = {};
|
||||
|
|
@ -329,20 +333,11 @@ app.controller('ConfigCtrl', function($scope, $http) {
|
|||
{% endif -%}
|
||||
$scope.inc_invalid = {};
|
||||
$.ajax({
|
||||
url: api,
|
||||
type: 'POST',
|
||||
data: {'path': path}
|
||||
url: api,
|
||||
type: 'GET',
|
||||
data: {'path': path}
|
||||
}).fail(function(xhr, stat, err) {
|
||||
/* display errors if something went wrong HTTP side */
|
||||
var msg = '<strong>ERROR:</strong> ';
|
||||
if (stat && err) {
|
||||
msg += '<p>'+stat+'</p><pre>'+err+'</pre>';
|
||||
} else if (stat) {
|
||||
msg += '<p>'+stat+'</p>';
|
||||
} else if (err) {
|
||||
msg += '<pre>'+err+'</pre>';
|
||||
}
|
||||
notif(2, msg, 10000);
|
||||
$scope.showError(stat, err);
|
||||
}).done(function(data) {
|
||||
/* The server answered correctly but some errors may have occurred server
|
||||
* side so we display them */
|
||||
|
|
@ -357,33 +352,55 @@ app.controller('ConfigCtrl', function($scope, $http) {
|
|||
}
|
||||
});
|
||||
};
|
||||
$scope.getClientsList = function() {
|
||||
api = '{{ url_for("api.clients_list", server=server) }}';
|
||||
$.ajax({
|
||||
url: api,
|
||||
type: 'GET'
|
||||
}).done(function(data) {
|
||||
$scope.clients = data.result;
|
||||
});
|
||||
};
|
||||
$scope.deleteClient = function() {
|
||||
api = '{{ url_for("api.delete_client", client=client, server=server) }}';
|
||||
$.ajax({
|
||||
url: api,
|
||||
type: 'POST'
|
||||
url: api,
|
||||
type: 'DELETE'
|
||||
}).fail(function(xhr, stat, err) {
|
||||
/* display errors if something went wrong HTTP side */
|
||||
var msg = '<strong>ERROR:</strong> ';
|
||||
if (stat && err) {
|
||||
msg += '<p>'+stat+'</p><pre>'+err+'</pre>';
|
||||
} else if (stat) {
|
||||
msg += '<p>'+stat+'</p>';
|
||||
} else if (err) {
|
||||
msg += '<pre>'+err+'</pre>';
|
||||
}
|
||||
notif(2, msg, 10000);
|
||||
$scope.showError(stat, err);
|
||||
}).done(function(data) {
|
||||
/* The server answered correctly but some errors may have occurred server
|
||||
* side so we display them */
|
||||
if (data.notif) {
|
||||
notif(data.notif[0], data.notif[1])
|
||||
notif(data.notif[0], data.notif[1]);
|
||||
if (data.notif[0] == 0) {
|
||||
document.location = '{{ url_for("view.settings", server=server) }}';
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
$scope.createClient = function(e) {
|
||||
/* we disable the 'real' form submission */
|
||||
e.preventDefault();
|
||||
var form = $(e.target);
|
||||
$.ajax({
|
||||
url: form.attr('action'),
|
||||
type: 'PUT',
|
||||
data: form.serialize()
|
||||
}).fail(function(xhr, stat, err) {
|
||||
$scope.showError(stat, err);
|
||||
}).done(function(data) {
|
||||
/* The server answered correctly but some errors may have occurred server
|
||||
* side so we display them */
|
||||
if (data.notif) {
|
||||
notif(data.notif[0][0], data.notif[0][1]);
|
||||
if (data.notif[0][0] == 0) {
|
||||
$scope.getClientsList();
|
||||
notif(data.notif[1][0], data.notif[1][1], 20000);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
/* These callbacks expand/reduce the input for a better readability */
|
||||
$scope.focusIn = function(ev) {
|
||||
el = $( ev.target ).parent();
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@
|
|||
|
||||
<div id="settings-panel" class="form-container" style="display:none;">
|
||||
{% if client -%}
|
||||
<form class="form-horizontal" action="{{ url_for('view.cli_settings', client=client, conf=conf, server=server) }}" method="POST" ng-submit="submit($event)" name="setSettings" onbeforeunload>
|
||||
<form class="form-horizontal" action="{{ url_for('api.client_settings', client=client, conf=conf, server=server) }}" method="POST" ng-submit="submit($event)" name="setSettings" onbeforeunload>
|
||||
{% else -%}
|
||||
<form class="form-horizontal" action="{{ url_for('view.settings', conf=conf, server=server) }}" method="POST" ng-submit="submit($event)" name="setSettings" onbeforeunload>
|
||||
<form class="form-horizontal" action="{{ url_for('api.server_settings', conf=conf, server=server) }}" method="POST" ng-submit="submit($event)" name="setSettings" onbeforeunload>
|
||||
{% endif -%}
|
||||
{# From here, the jinja syntax is escaped because we use the angularjs syntax #}
|
||||
{% raw %}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
</ul>
|
||||
<ul class="nav nav-sidebar">
|
||||
<li>
|
||||
<form action="{{ url_for('api.new_client') }}" method="POST">
|
||||
<form action="{{ url_for('api.new_client') }}" method="POST" ng-submit="createClient($event)">
|
||||
<div class="input-group">
|
||||
<input class="form-control" type="text" name="newclient" id="newclient" placeholder="Create new client">
|
||||
<span class="input-group-btn">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue