mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-03-28 17:42:56 +01:00
Implement Member Editor admin interface
Add a complete CRUD interface for managing network members at /a/members, following the proven pattern established by the Tag Editor. Changes: - Add member routes to admin.py (GET, POST create/update/delete) - Create admin/members.html template with member table, forms, and modals - Add Members navigation card to admin index page - Include proper authentication checks and flash message handling - Fix mypy type hints for optional form fields The Member Editor allows admins to: - View all network members in a sortable table - Create new members with all fields (member_id, name, callsign, role, contact, description) - Edit existing members via modal dialog - Delete members with confirmation - Client-side validation for member_id format (alphanumeric + underscore) All backend API infrastructure (models, schemas, routes) was already implemented. This is purely a web UI layer built on top of the existing /api/v1/members endpoints.
This commit is contained in:
@@ -391,3 +391,201 @@ async def admin_delete_all_tags(
|
||||
redirect_url = _build_redirect_url(public_key, error="Failed to delete tags")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
def _build_members_redirect_url(
|
||||
message: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Build a properly encoded redirect URL for members page with optional message/error."""
|
||||
params: dict[str, str] = {}
|
||||
if message:
|
||||
params["message"] = message
|
||||
if error:
|
||||
params["error"] = error
|
||||
if params:
|
||||
return f"/a/members?{urlencode(params)}"
|
||||
return "/a/members"
|
||||
|
||||
|
||||
@router.get("/members", response_class=HTMLResponse)
|
||||
async def admin_members(
|
||||
request: Request,
|
||||
message: Optional[str] = Query(None),
|
||||
error: Optional[str] = Query(None),
|
||||
) -> HTMLResponse:
|
||||
"""Admin page for managing members."""
|
||||
_check_admin_enabled(request)
|
||||
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
context.update(_get_auth_context(request))
|
||||
|
||||
# Check if user is authenticated
|
||||
if not _is_authenticated(request):
|
||||
return templates.TemplateResponse(
|
||||
"admin/access_denied.html", context, status_code=403
|
||||
)
|
||||
|
||||
# Flash messages from redirects
|
||||
context["message"] = message
|
||||
context["error"] = error
|
||||
|
||||
# Fetch all members
|
||||
members = []
|
||||
try:
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/members",
|
||||
params={"limit": 1000},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
members = data.get("items", [])
|
||||
# Sort members alphabetically by name
|
||||
members.sort(key=lambda m: m.get("name", "").lower())
|
||||
except Exception as e:
|
||||
logger.exception("Failed to fetch members: %s", e)
|
||||
context["error"] = "Failed to fetch members"
|
||||
|
||||
context["members"] = members
|
||||
|
||||
return templates.TemplateResponse("admin/members.html", context)
|
||||
|
||||
|
||||
@router.post("/members", response_class=RedirectResponse)
|
||||
async def admin_create_member(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
member_id: str = Form(...),
|
||||
callsign: Optional[str] = Form(None),
|
||||
role: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
contact: Optional[str] = Form(None),
|
||||
) -> RedirectResponse:
|
||||
"""Create a new member."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
# Build request payload
|
||||
payload = {
|
||||
"name": name,
|
||||
"member_id": member_id,
|
||||
}
|
||||
if callsign:
|
||||
payload["callsign"] = callsign
|
||||
if role:
|
||||
payload["role"] = role
|
||||
if description:
|
||||
payload["description"] = description
|
||||
if contact:
|
||||
payload["contact"] = contact
|
||||
|
||||
response = await request.app.state.http_client.post(
|
||||
"/api/v1/members",
|
||||
json=payload,
|
||||
)
|
||||
if response.status_code == 201:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
message=f"Member '{name}' created successfully"
|
||||
)
|
||||
elif response.status_code == 409:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
error=f"Member ID '{member_id}' already exists"
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to create member: %s", e)
|
||||
redirect_url = _build_members_redirect_url(error="Failed to create member")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/members/update", response_class=RedirectResponse)
|
||||
async def admin_update_member(
|
||||
request: Request,
|
||||
id: str = Form(...),
|
||||
name: Optional[str] = Form(None),
|
||||
member_id: Optional[str] = Form(None),
|
||||
callsign: Optional[str] = Form(None),
|
||||
role: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
contact: Optional[str] = Form(None),
|
||||
) -> RedirectResponse:
|
||||
"""Update an existing member."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
# Build update payload (only include non-None fields)
|
||||
payload: dict[str, str | None] = {}
|
||||
if name is not None:
|
||||
payload["name"] = name
|
||||
if member_id is not None:
|
||||
payload["member_id"] = member_id
|
||||
if callsign is not None:
|
||||
payload["callsign"] = callsign if callsign else None
|
||||
if role is not None:
|
||||
payload["role"] = role if role else None
|
||||
if description is not None:
|
||||
payload["description"] = description if description else None
|
||||
if contact is not None:
|
||||
payload["contact"] = contact if contact else None
|
||||
|
||||
response = await request.app.state.http_client.put(
|
||||
f"/api/v1/members/{id}",
|
||||
json=payload,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
message="Member updated successfully"
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_members_redirect_url(error="Member not found")
|
||||
elif response.status_code == 409:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
error=f"Member ID '{member_id}' already exists"
|
||||
)
|
||||
else:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to update member: %s", e)
|
||||
redirect_url = _build_members_redirect_url(error="Failed to update member")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/members/delete", response_class=RedirectResponse)
|
||||
async def admin_delete_member(
|
||||
request: Request,
|
||||
id: str = Form(...),
|
||||
) -> RedirectResponse:
|
||||
"""Delete a member."""
|
||||
_check_admin_enabled(request)
|
||||
_require_auth(request)
|
||||
|
||||
try:
|
||||
response = await request.app.state.http_client.delete(
|
||||
f"/api/v1/members/{id}",
|
||||
)
|
||||
if response.status_code == 204:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
message="Member deleted successfully"
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
redirect_url = _build_members_redirect_url(error="Member not found")
|
||||
else:
|
||||
redirect_url = _build_members_redirect_url(
|
||||
error=_get_error_detail(response)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to delete member: %s", e)
|
||||
redirect_url = _build_members_redirect_url(error="Failed to delete member")
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
@@ -49,5 +49,17 @@
|
||||
<p>Manage custom tags and metadata for network nodes.</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/a/members" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
Members
|
||||
</h2>
|
||||
<p>Manage network members and operators.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
295
src/meshcore_hub/web/templates/admin/members.html
Normal file
295
src/meshcore_hub/web/templates/admin/members.html
Normal file
@@ -0,0 +1,295 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Members Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Members</h1>
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/a/">Admin</a></li>
|
||||
<li>Members</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/oauth2/sign_out" class="btn btn-outline btn-sm">Sign Out</a>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% if message %}
|
||||
<div class="alert alert-success mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ message }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Members Table -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Network Members ({{ members|length }})</h2>
|
||||
|
||||
{% if members %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member ID</th>
|
||||
<th>Name</th>
|
||||
<th>Callsign</th>
|
||||
<th>Role</th>
|
||||
<th>Contact</th>
|
||||
<th class="w-32">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for member in members %}
|
||||
<tr data-member-id="{{ member.id }}"
|
||||
data-member-name="{{ member.name }}"
|
||||
data-member-member-id="{{ member.member_id }}"
|
||||
data-member-callsign="{{ member.callsign or '' }}"
|
||||
data-member-role="{{ member.role or '' }}"
|
||||
data-member-description="{{ member.description or '' }}"
|
||||
data-member-contact="{{ member.contact or '' }}">
|
||||
<td class="font-mono font-semibold">{{ member.member_id }}</td>
|
||||
<td>{{ member.name }}</td>
|
||||
<td>
|
||||
{% if member.callsign %}
|
||||
<span class="badge badge-primary">{{ member.callsign }}</span>
|
||||
{% else %}
|
||||
<span class="text-base-content/40">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="max-w-xs truncate" title="{{ member.role or '' }}">{{ member.role or '-' }}</td>
|
||||
<td class="max-w-xs truncate" title="{{ member.contact or '' }}">{{ member.contact or '-' }}</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-ghost btn-xs btn-edit">
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xs text-error btn-delete">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<p>No members configured yet.</p>
|
||||
<p class="text-sm mt-2">Add a new member below.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add New Member Form -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Add New Member</h2>
|
||||
<form method="post" action="/a/members">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Member ID <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input type="text" name="member_id" class="input input-bordered"
|
||||
placeholder="walshie86" required maxlength="50"
|
||||
pattern="[a-zA-Z0-9_]+"
|
||||
title="Letters, numbers, and underscores only">
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Unique identifier (letters, numbers, underscore)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Name <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input type="text" name="name" class="input input-bordered"
|
||||
placeholder="John Smith" required maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Callsign</span>
|
||||
</label>
|
||||
<input type="text" name="callsign" class="input input-bordered"
|
||||
placeholder="VK4ABC" maxlength="20">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Role</span>
|
||||
</label>
|
||||
<input type="text" name="role" class="input input-bordered"
|
||||
placeholder="Network Coordinator" maxlength="100">
|
||||
</div>
|
||||
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text">Contact</span>
|
||||
</label>
|
||||
<input type="text" name="contact" class="input input-bordered"
|
||||
placeholder="john@example.com or phone number" maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text">Description</span>
|
||||
</label>
|
||||
<textarea name="description" rows="3" class="textarea textarea-bordered"
|
||||
placeholder="Brief description of member's role and responsibilities..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control mt-4">
|
||||
<button type="submit" class="btn btn-primary">Add Member</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<dialog id="editModal" class="modal">
|
||||
<div class="modal-box w-11/12 max-w-2xl">
|
||||
<h3 class="font-bold text-lg">Edit Member</h3>
|
||||
<form method="post" action="/a/members/update" class="py-4">
|
||||
<input type="hidden" name="id" id="edit_id">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Member ID <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input type="text" name="member_id" id="edit_member_id" class="input input-bordered"
|
||||
required maxlength="50" pattern="[a-zA-Z0-9_]+"
|
||||
title="Letters, numbers, and underscores only">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Name <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input type="text" name="name" id="edit_name" class="input input-bordered"
|
||||
required maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Callsign</span>
|
||||
</label>
|
||||
<input type="text" name="callsign" id="edit_callsign" class="input input-bordered"
|
||||
maxlength="20">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Role</span>
|
||||
</label>
|
||||
<input type="text" name="role" id="edit_role" class="input input-bordered"
|
||||
maxlength="100">
|
||||
</div>
|
||||
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text">Contact</span>
|
||||
</label>
|
||||
<input type="text" name="contact" id="edit_contact" class="input input-bordered"
|
||||
maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text">Description</span>
|
||||
</label>
|
||||
<textarea name="description" id="edit_description" rows="3"
|
||||
class="textarea textarea-bordered"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="editModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<dialog id="deleteModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Delete Member</h3>
|
||||
<form method="post" action="/a/members/delete" class="py-4">
|
||||
<input type="hidden" name="id" id="delete_id">
|
||||
|
||||
<p class="py-4">Are you sure you want to delete member <strong id="delete_member_name"></strong>?</p>
|
||||
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>This action cannot be undone.</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="deleteModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-error">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Use event delegation to handle button clicks safely
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Edit button handler
|
||||
document.querySelectorAll('.btn-edit').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var row = this.closest('tr');
|
||||
document.getElementById('edit_id').value = row.dataset.memberId;
|
||||
document.getElementById('edit_member_id').value = row.dataset.memberMemberId;
|
||||
document.getElementById('edit_name').value = row.dataset.memberName;
|
||||
document.getElementById('edit_callsign').value = row.dataset.memberCallsign;
|
||||
document.getElementById('edit_role').value = row.dataset.memberRole;
|
||||
document.getElementById('edit_description').value = row.dataset.memberDescription;
|
||||
document.getElementById('edit_contact').value = row.dataset.memberContact;
|
||||
editModal.showModal();
|
||||
});
|
||||
});
|
||||
|
||||
// Delete button handler
|
||||
document.querySelectorAll('.btn-delete').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var row = this.closest('tr');
|
||||
document.getElementById('delete_id').value = row.dataset.memberId;
|
||||
document.getElementById('delete_member_name').textContent = row.dataset.memberName;
|
||||
deleteModal.showModal();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user