feat: Implement server-side search for users

This commit is contained in:
ReturnFI
2025-11-08 19:51:42 +00:00
parent 772d363fa1
commit 7ddb30f75f
4 changed files with 311 additions and 171 deletions

View File

@ -11,10 +11,12 @@ $(function () {
const BULK_URI_URL = contentSection.dataset.bulkUriUrl; const BULK_URI_URL = contentSection.dataset.bulkUriUrl;
const USERS_BASE_URL = contentSection.dataset.usersBaseUrl; const USERS_BASE_URL = contentSection.dataset.usersBaseUrl;
const GET_USER_URL_TEMPLATE = contentSection.dataset.getUserUrlTemplate; const GET_USER_URL_TEMPLATE = contentSection.dataset.getUserUrlTemplate;
const SEARCH_USERS_URL = contentSection.dataset.searchUrl;
const usernameRegex = /^[a-zA-Z0-9_]+$/; const usernameRegex = /^[a-zA-Z0-9_]+$/;
const passwordRegex = /^[a-zA-Z0-9]+$/; const passwordRegex = /^[a-zA-Z0-9]+$/;
let cachedUserData = []; let cachedUserData = [];
let searchTimeout = null;
function setCookie(name, value, days) { function setCookie(name, value, days) {
let expires = ""; let expires = "";
@ -65,12 +67,80 @@ $(function () {
function validatePassword(inputElement, errorElement) { function validatePassword(inputElement, errorElement) {
const password = $(inputElement).val(); const password = $(inputElement).val();
// The password is valid if it's empty (no change) OR it matches the alphanumeric regex.
const isValid = password === '' || passwordRegex.test(password); const isValid = password === '' || passwordRegex.test(password);
$(errorElement).text(isValid ? "" : "Password can only contain letters and numbers."); $(errorElement).text(isValid ? "" : "Password can only contain letters and numbers.");
$('#editSubmitButton').prop('disabled', !isValid); $('#editSubmitButton').prop('disabled', !isValid);
} }
function refreshUserList() {
const query = $("#searchInput").val().trim();
if (query !== "") {
performSearch();
} else {
restoreInitialView();
}
}
function performSearch() {
const query = $("#searchInput").val().trim();
const $userTableBody = $("#userTableBody");
const $paginationContainer = $("#paginationContainer");
const $userTotalCount = $("#user-total-count");
$paginationContainer.hide();
$userTableBody.css('opacity', 0.5).html('<tr><td colspan="14" class="text-center p-4"><i class="fas fa-spinner fa-spin"></i> Searching...</td></tr>');
$.ajax({
url: SEARCH_USERS_URL,
type: 'GET',
data: { q: query },
success: function (data) {
$userTableBody.html(data);
checkIpLimitServiceStatus();
const resultCount = $userTableBody.find('tr.user-main-row').length;
$userTotalCount.text(resultCount);
},
error: function () {
Swal.fire("Error!", "An error occurred during search.", "error");
$userTableBody.html('<tr><td colspan="14" class="text-center p-4 text-danger">Search failed to load.</td></tr>');
},
complete: function () {
$userTableBody.css('opacity', 1);
}
});
}
function restoreInitialView() {
const $userTableBody = $("#userTableBody");
const $paginationContainer = $("#paginationContainer");
const $userTotalCount = $("#user-total-count");
$userTableBody.css('opacity', 0.5).html('<tr><td colspan="14" class="text-center p-4"><i class="fas fa-spinner fa-spin"></i> Loading users...</td></tr>');
$.ajax({
url: USERS_BASE_URL,
type: 'GET',
success: function (data) {
const newBody = $(data).find('#userTableBody').html();
const newPagination = $(data).find('#paginationContainer').html();
const newTotalCount = $(data).find('#user-total-count').text();
$userTableBody.html(newBody);
$paginationContainer.html(newPagination).show();
$userTotalCount.text(newTotalCount);
checkIpLimitServiceStatus();
},
error: function () {
Swal.fire("Error!", "Could not restore the user list.", "error");
$userTableBody.html('<tr><td colspan="14" class="text-center p-4 text-danger">Failed to load users. Please refresh the page.</td></tr>');
},
complete: function () {
$userTableBody.css('opacity', 1);
}
});
}
$('#editPassword').on('input', function() { $('#editPassword').on('input', function() {
validatePassword(this, '#editPasswordError'); validatePassword(this, '#editPasswordError');
}); });
@ -118,6 +188,13 @@ $(function () {
}).then((result) => { }).then((result) => {
if (!result.isConfirmed) return; if (!result.isConfirmed) return;
Swal.fire({
title: 'Deleting...',
text: 'Please wait',
allowOutsideClick: false,
didOpen: () => Swal.showLoading()
});
if (selectedUsers.length > 1) { if (selectedUsers.length > 1) {
$.ajax({ $.ajax({
url: BULK_REMOVE_URL, url: BULK_REMOVE_URL,
@ -125,7 +202,7 @@ $(function () {
contentType: "application/json", contentType: "application/json",
data: JSON.stringify({ usernames: selectedUsers }) data: JSON.stringify({ usernames: selectedUsers })
}) })
.done(() => Swal.fire("Success!", "Selected users have been deleted.", "success").then(() => location.reload())) .done(() => Swal.fire("Success!", "Selected users have been deleted.", "success").then(() => refreshUserList()))
.fail((err) => Swal.fire("Error!", err.responseJSON?.detail || "An error occurred while deleting users.", "error")); .fail((err) => Swal.fire("Error!", err.responseJSON?.detail || "An error occurred while deleting users.", "error"));
} else { } else {
const singleUrl = REMOVE_USER_URL_TEMPLATE.replace('U', selectedUsers[0]); const singleUrl = REMOVE_USER_URL_TEMPLATE.replace('U', selectedUsers[0]);
@ -133,7 +210,7 @@ $(function () {
url: singleUrl, url: singleUrl,
method: "DELETE" method: "DELETE"
}) })
.done(() => Swal.fire("Success!", "The user has been deleted.", "success").then(() => location.reload())) .done(() => Swal.fire("Success!", "The user has been deleted.", "success").then(() => refreshUserList()))
.fail((err) => Swal.fire("Error!", err.responseJSON?.detail || "An error occurred while deleting the user.", "error")); .fail((err) => Swal.fire("Error!", err.responseJSON?.detail || "An error occurred while deleting the user.", "error"));
} }
}); });
@ -151,13 +228,23 @@ $(function () {
jsonData.unlimited = jsonData.unlimited === 'on'; jsonData.unlimited = jsonData.unlimited === 'on';
Swal.fire({
title: 'Adding...',
text: 'Please wait',
allowOutsideClick: false,
didOpen: () => Swal.showLoading()
});
$.ajax({ $.ajax({
url: url, url: url,
method: "POST", method: "POST",
contentType: "application/json", contentType: "application/json",
data: JSON.stringify(jsonData), data: JSON.stringify(jsonData),
}) })
.done(res => Swal.fire("Success!", res.detail, "success").then(() => location.reload())) .done(res => {
$('#addUserModal').modal('hide');
Swal.fire("Success!", res.detail, "success").then(() => refreshUserList());
})
.fail(err => Swal.fire("Error!", err.responseJSON?.detail || "An error occurred.", "error")) .fail(err => Swal.fire("Error!", err.responseJSON?.detail || "An error occurred.", "error"))
.always(() => button.prop('disabled', false)); .always(() => button.prop('disabled', false));
}); });
@ -213,13 +300,23 @@ $(function () {
jsonData.blocked = jsonData.blocked === 'on'; jsonData.blocked = jsonData.blocked === 'on';
jsonData.unlimited_ip = jsonData.unlimited_ip === 'on'; jsonData.unlimited_ip = jsonData.unlimited_ip === 'on';
Swal.fire({
title: 'Updating...',
text: 'Please wait',
allowOutsideClick: false,
didOpen: () => Swal.showLoading()
});
$.ajax({ $.ajax({
url: url, url: url,
method: "PATCH", method: "PATCH",
contentType: "application/json", contentType: "application/json",
data: JSON.stringify(jsonData), data: JSON.stringify(jsonData),
}) })
.done(res => Swal.fire("Success!", res.detail, "success").then(() => location.reload())) .done(res => {
$('#editUserModal').modal('hide');
Swal.fire("Success!", res.detail, "success").then(() => refreshUserList());
})
.fail(err => Swal.fire("Error!", err.responseJSON?.detail, "error")) .fail(err => Swal.fire("Error!", err.responseJSON?.detail, "error"))
.always(() => button.prop('disabled', false)); .always(() => button.prop('disabled', false));
}); });
@ -240,11 +337,19 @@ $(function () {
confirmButtonText: `Yes, ${action} it!`, confirmButtonText: `Yes, ${action} it!`,
}).then((result) => { }).then((result) => {
if (!result.isConfirmed) return; if (!result.isConfirmed) return;
Swal.fire({
title: `${action.charAt(0).toUpperCase() + action.slice(1)}ing...`,
text: 'Please wait',
allowOutsideClick: false,
didOpen: () => Swal.showLoading()
});
$.ajax({ $.ajax({
url: urlTemplate.replace("U", encodeURIComponent(username)), url: urlTemplate.replace("U", encodeURIComponent(username)),
method: isDelete ? "DELETE" : "GET", method: isDelete ? "DELETE" : "GET",
}) })
.done(res => Swal.fire("Success!", res.detail, "success").then(() => location.reload())) .done(res => Swal.fire("Success!", res.detail, "success").then(() => refreshUserList()))
.fail(() => Swal.fire("Error!", `Failed to ${action} user.`, "error")); .fail(() => Swal.fire("Error!", `Failed to ${action} user.`, "error"));
}); });
}); });
@ -352,19 +457,6 @@ $(function () {
.then(() => Swal.fire({ icon: "success", title: "Links copied!", showConfirmButton: false, timer: 1200 })); .then(() => Swal.fire({ icon: "success", title: "Links copied!", showConfirmButton: false, timer: 1200 }));
}); });
function filterUsers() {
const searchText = $("#searchInput").val().toLowerCase();
$("#userTable tbody tr.user-main-row").each(function () {
const username = $(this).find("td:eq(2)").text().toLowerCase();
const note = $(this).data("note").toLowerCase();
const isVisible = username.includes(searchText) || note.includes(searchText);
$(this).toggle(isVisible);
if (!isVisible) {
$(this).next('tr.user-details-row').hide();
}
});
}
$('#userTable').on('click', '.toggle-details-btn', function() { $('#userTable').on('click', '.toggle-details-btn', function() {
const $this = $(this); const $this = $(this);
const icon = $this.find('i'); const icon = $this.find('i');
@ -390,8 +482,23 @@ $(function () {
$('#addUserModal a[data-toggle="tab"]').first().tab('show'); $('#addUserModal a[data-toggle="tab"]').first().tab('show');
}); });
$("#searchButton").on("click", filterUsers); $("#searchButton").on("click", performSearch);
$("#searchInput").on("keyup", filterUsers); $("#searchInput").on("keyup", function (e) {
clearTimeout(searchTimeout);
const query = $(this).val().trim();
if (e.key === 'Enter') {
performSearch();
return;
}
if (query === "") {
searchTimeout = setTimeout(restoreInitialView, 300);
return;
}
searchTimeout = setTimeout(performSearch, 500);
});
function initializeLimitSelector() { function initializeLimitSelector() {
const savedLimit = getCookie('limit') || '50'; const savedLimit = getCookie('limit') || '50';

View File

@ -65,4 +65,32 @@ async def users_root(
templates: Jinja2Templates = Depends(get_templates), templates: Jinja2Templates = Depends(get_templates),
limit: int = Cookie(default=50, ge=1) limit: int = Cookie(default=50, ge=1)
): ):
return await get_users_page(request, templates, 1, limit) return await get_users_page(request, templates, 1, limit)
@router.get("/search/", name="search_users")
async def search_users(
request: Request,
q: str = Query(""),
templates: Jinja2Templates = Depends(get_templates)
):
try:
if not q:
all_users_data = []
else:
all_users_data = cli_api.list_users() or []
query = q.lower()
filtered_users_data = [
user_data for user_data in all_users_data
if query in user_data.get('username', '').lower() or query in user_data.get('note', '').lower()
]
users: list[User] = [User.from_dict(user_data.get('username', ''), user_data) for user_data in filtered_users_data]
return templates.TemplateResponse(
'users_rows.html',
{'request': request, 'users': users}
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -49,11 +49,12 @@
data-user-uri-url-template="{{ url_for('show_user_uri_api', username='U') }}" data-user-uri-url-template="{{ url_for('show_user_uri_api', username='U') }}"
data-bulk-uri-url="{{ url_for('show_multiple_user_uris_api') }}" data-bulk-uri-url="{{ url_for('show_multiple_user_uris_api') }}"
data-users-base-url="{{ url_for('users') }}" data-users-base-url="{{ url_for('users') }}"
data-get-user-url-template="{{ url_for('get_user_api', username='U') }}"> data-get-user-url-template="{{ url_for('get_user_api', username='U') }}"
data-search-url="{{ url_for('search_users') }}">
<div class="container-fluid"> <div class="container-fluid">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">User List {% if total_users is defined %}({{ total_users }}){% endif %}</h3> <h3 class="card-title">User List {% if total_users is defined %}(<span id="user-total-count">{{ total_users }}</span>){% endif %}</h3>
<div class="card-tools d-flex align-items-center flex-wrap"> <div class="card-tools d-flex align-items-center flex-wrap">
<!-- Mobile Filter Dropdown --> <!-- Mobile Filter Dropdown -->
@ -117,11 +118,6 @@
</div> </div>
</div> </div>
<div class="card-body table-responsive p-0"> <div class="card-body table-responsive p-0">
{% if not users %}
<div class="alert alert-warning m-3" role="alert">
No users found.
</div>
{% else %}
<table class="table table-bordered table-hover" id="userTable"> <table class="table table-bordered table-hover" id="userTable">
<thead> <thead>
<tr> <tr>
@ -143,151 +139,13 @@
<th class="d-md-none text-center">Details</th> <th class="d-md-none text-center">Details</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="userTableBody">
{% for user in users|sort(attribute='username', case_sensitive=false) %} {% set offset = (current_page - 1) * limit %}
<tr class="user-main-row" data-note="{{ user.note or '' }}"> {% include 'users_rows.html' %}
<td>
<input type="checkbox" class="user-checkbox" value="{{ user.username }}">
</td>
<td>{{ loop.index + ((current_page - 1) * limit) }}</td>
<td data-username="{{ user.username }}">{{ user.username }}</td>
<td class="d-none d-md-table-cell">
{% if user.status == "Online" %}
<i class="fas fa-circle text-success"></i> Online
{% if user.online_count and user.online_count > 0 %}
({{ user.online_count }})
{% endif %}
{% elif user.status == "Offline" %}
<i class="fas fa-circle text-secondary"></i> Offline
{% elif user.status == "On-hold" %}
<i class="fas fa-circle text-warning"></i> On-hold
{% elif user.status == "Conflict" %}
<i class="fas fa-circle text-danger"></i> Conflict
{% else %}
<i class="fas fa-circle text-danger"></i> {{ user.status }}
{% endif %}
</td>
<td class="d-none d-md-table-cell">{{ user.traffic_used }}</td>
<td class="d-none d-md-table-cell">{{ user.expiry_date }}</td>
<td class="d-none d-md-table-cell">{{ user.expiry_days }}</td>
<td class="d-none d-md-table-cell">{{ user.day_usage }}</td>
<td class="d-none d-md-table-cell">
{% if user.enable %}
<i class="fas fa-check-circle text-success"></i>
{% else %}
<i class="fas fa-times-circle text-danger"></i>
{% endif %}
</td>
<td class="d-none d-md-table-cell note-cell">
{% if user.note %}
<span title="{{ user.note }}">{{ user.note | truncate(20, True) }}</span>
{% endif %}
</td>
<td class="d-none d-md-table-cell unlimited-ip-cell requires-iplimit-service" style="display: none;">
{% if user.unlimited_ip %}
<i class="fas fa-shield-alt text-primary"></i>
{% else %}
<i class="fas fa-times-circle text-muted"></i>
{% endif %}
</td>
<td class="d-none d-md-table-cell text-nowrap">
<a href="#" class="config-link" data-toggle="modal" data-target="#qrcodeModal"
data-username="{{ user.username }}">
<i class="fas fa-qrcode"></i>
</a>
</td>
<td class="d-none d-md-table-cell text-nowrap">
<button type="button" class="btn btn-sm btn-info edit-user"
data-user="{{ user.username }}" data-toggle="modal"
data-target="#editUserModal">
<i class="fas fa-edit"></i>
</button>
<button type="button" class="btn btn-sm btn-warning reset-user"
data-user="{{ user.username }}">
<i class="fas fa-undo"></i>
</button>
<button type="button" class="btn btn-sm btn-danger delete-user"
data-user="{{ user.username }}">
<i class="fas fa-trash"></i>
</button>
</td>
<td class="d-md-none text-center">
<button type="button" class="btn btn-sm btn-secondary toggle-details-btn">
<i class="fas fa-plus"></i>
</button>
</td>
</tr>
<tr class="user-details-row d-md-none" style="display: none;">
<td colspan="4">
<div class="user-details-content p-2">
<p><strong>Status:</strong>
<span>
{% if user.status == "Online" %}
<i class="fas fa-circle text-success"></i> Online
{% if user.online_count and user.online_count > 0 %}
({{ user.online_count }})
{% endif %}
{% elif user.status == "Offline" %}
<i class="fas fa-circle text-secondary"></i> Offline
{% elif user.status == "On-hold" %}
<i class="fas fa-circle text-warning"></i> On-hold
{% elif user.status == "Conflict" %}
<i class="fas fa-circle text-danger"></i> Conflict
{% else %}
<i class="fas fa-circle text-danger"></i> {{ user.status }}
{% endif %}
</span>
</p>
<p><strong>Traffic Usage:</strong> <span>{{ user.traffic_used }}</span></p>
<p><strong>Expiry Date:</strong> <span>{{ user.expiry_date }}</span></p>
<p><strong>Expiry Days:</strong> <span>{{ user.expiry_days }}</span></p>
<p><strong>Day Usage:</strong> <span>{{ user.day_usage }}</span></p>
<p><strong>Note:</strong> <span>{{ user.note or 'N/A' }}</span></p>
<p><strong>Enable:</strong>
<span>
{% if user.enable %}
<i class="fas fa-check-circle text-success"></i> Enabled
{% else %}
<i class="fas fa-times-circle text-danger"></i> Disabled
{% endif %}
</span>
</p>
<p class="requires-iplimit-service" style="display: none;"><strong>Unlimited IP:</strong>
<span>
{% if user.unlimited_ip %}
<i class="fas fa-shield-alt text-primary"></i> Yes
{% else %}
<i class="fas fa-times-circle text-muted"></i> No
{% endif %}
</span>
</p>
<div class="mt-2">
<strong>Configs:</strong>
<a href="#" class="btn btn-sm btn-outline-secondary config-link" data-toggle="modal" data-target="#qrcodeModal" data-username="{{ user.username }}">
<i class="fas fa-qrcode"></i> Show
</a>
</div>
<div class="mt-2 user-details-actions">
<strong>Actions:</strong>
<button type="button" class="btn btn-sm btn-info edit-user" data-user="{{ user.username }}" data-toggle="modal" data-target="#editUserModal">
<i class="fas fa-edit"></i> Edit
</button>
<button type="button" class="btn btn-sm btn-warning reset-user" data-user="{{ user.username }}">
<i class="fas fa-undo"></i> Reset
</button>
<button type="button" class="btn btn-sm btn-danger delete-user" data-user="{{ user.username }}">
<i class="fas fa-trash"></i> Delete
</button>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody> </tbody>
</table> </table>
{% endif %}
</div> </div>
<div class="card-footer clearfix"> <div class="card-footer clearfix" id="paginationContainer">
<div class="row"> <div class="row">
<div class="col-sm-12 col-md-5 d-flex align-items-center"> <div class="col-sm-12 col-md-5 d-flex align-items-center">
<div class="dataTables_info" role="status" aria-live="polite"> <div class="dataTables_info" role="status" aria-live="polite">

View File

@ -0,0 +1,147 @@
{% if not users %}
<tr>
<td colspan="14" class="text-center p-4">
No users found.
</td>
</tr>
{% else %}
{% for user in users|sort(attribute='username', case_sensitive=false) %}
<tr class="user-main-row" data-note="{{ user.note or '' }}">
<td>
<input type="checkbox" class="user-checkbox" value="{{ user.username }}">
</td>
<td>{{ loop.index + (offset if offset is defined else 0) }}</td>
<td data-username="{{ user.username }}">{{ user.username }}</td>
<td class="d-none d-md-table-cell">
{% if user.status == "Online" %}
<i class="fas fa-circle text-success"></i> Online
{% if user.online_count and user.online_count > 0 %}
({{ user.online_count }})
{% endif %}
{% elif user.status == "Offline" %}
<i class="fas fa-circle text-secondary"></i> Offline
{% elif user.status == "On-hold" %}
<i class="fas fa-circle text-warning"></i> On-hold
{% elif user.status == "Conflict" %}
<i class="fas fa-circle text-danger"></i> Conflict
{% else %}
<i class="fas fa-circle text-danger"></i> {{ user.status }}
{% endif %}
</td>
<td class="d-none d-md-table-cell">{{ user.traffic_used }}</td>
<td class="d-none d-md-table-cell">{{ user.expiry_date }}</td>
<td class="d-none d-md-table-cell">{{ user.expiry_days }}</td>
<td class="d-none d-md-table-cell">{{ user.day_usage }}</td>
<td class="d-none d-md-table-cell">
{% if user.enable %}
<i class="fas fa-check-circle text-success"></i>
{% else %}
<i class="fas fa-times-circle text-danger"></i>
{% endif %}
</td>
<td class="d-none d-md-table-cell note-cell">
{% if user.note %}
<span title="{{ user.note }}">{{ user.note | truncate(20, True) }}</span>
{% endif %}
</td>
<td class="d-none d-md-table-cell unlimited-ip-cell requires-iplimit-service" style="display: none;">
{% if user.unlimited_ip %}
<i class="fas fa-shield-alt text-primary"></i>
{% else %}
<i class="fas fa-times-circle text-muted"></i>
{% endif %}
</td>
<td class="d-none d-md-table-cell text-nowrap">
<a href="#" class="config-link" data-toggle="modal" data-target="#qrcodeModal"
data-username="{{ user.username }}">
<i class="fas fa-qrcode"></i>
</a>
</td>
<td class="d-none d-md-table-cell text-nowrap">
<button type="button" class="btn btn-sm btn-info edit-user"
data-user="{{ user.username }}" data-toggle="modal"
data-target="#editUserModal">
<i class="fas fa-edit"></i>
</button>
<button type="button" class="btn btn-sm btn-warning reset-user"
data-user="{{ user.username }}">
<i class="fas fa-undo"></i>
</button>
<button type="button" class="btn btn-sm btn-danger delete-user"
data-user="{{ user.username }}">
<i class="fas fa-trash"></i>
</button>
</td>
<td class="d-md-none text-center">
<button type="button" class="btn btn-sm btn-secondary toggle-details-btn">
<i class="fas fa-plus"></i>
</button>
</td>
</tr>
<tr class="user-details-row d-md-none" style="display: none;">
<td colspan="4">
<div class="user-details-content p-2">
<p><strong>Status:</strong>
<span>
{% if user.status == "Online" %}
<i class="fas fa-circle text-success"></i> Online
{% if user.online_count and user.online_count > 0 %}
({{ user.online_count }})
{% endif %}
{% elif user.status == "Offline" %}
<i class="fas fa-circle text-secondary"></i> Offline
{% elif user.status == "On-hold" %}
<i class="fas fa-circle text-warning"></i> On-hold
{% elif user.status == "Conflict" %}
<i class="fas fa-circle text-danger"></i> Conflict
{% else %}
<i class="fas fa-circle text-danger"></i> {{ user.status }}
{% endif %}
</span>
</p>
<p><strong>Traffic Usage:</strong> <span>{{ user.traffic_used }}</span></p>
<p><strong>Expiry Date:</strong> <span>{{ user.expiry_date }}</span></p>
<p><strong>Expiry Days:</strong> <span>{{ user.expiry_days }}</span></p>
<p><strong>Day Usage:</strong> <span>{{ user.day_usage }}</span></p>
<p><strong>Note:</strong> <span>{{ user.note or 'N/A' }}</span></p>
<p><strong>Enable:</strong>
<span>
{% if user.enable %}
<i class="fas fa-check-circle text-success"></i> Enabled
{% else %}
<i class="fas fa-times-circle text-danger"></i> Disabled
{% endif %}
</span>
</p>
<p class="requires-iplimit-service" style="display: none;"><strong>Unlimited IP:</strong>
<span>
{% if user.unlimited_ip %}
<i class="fas fa-shield-alt text-primary"></i> Yes
{% else %}
<i class="fas fa-times-circle text-muted"></i> No
{% endif %}
</span>
</p>
<div class="mt-2">
<strong>Configs:</strong>
<a href="#" class="btn btn-sm btn-outline-secondary config-link" data-toggle="modal" data-target="#qrcodeModal" data-username="{{ user.username }}">
<i class="fas fa-qrcode"></i> Show
</a>
</div>
<div class="mt-2 user-details-actions">
<strong>Actions:</strong>
<button type="button" class="btn btn-sm btn-info edit-user" data-user="{{ user.username }}" data-toggle="modal" data-target="#editUserModal">
<i class="fas fa-edit"></i> Edit
</button>
<button type="button" class="btn btn-sm btn-warning reset-user" data-user="{{ user.username }}">
<i class="fas fa-undo"></i> Reset
</button>
<button type="button" class="btn btn-sm btn-danger delete-user" data-user="{{ user.username }}">
<i class="fas fa-trash"></i> Delete
</button>
</div>
</div>
</td>
</tr>
{% endfor %}
{% endif %}