Merge pull request #65 from ipnet-mesh/claude/plan-member-editor-BwkcS

Plan Member Editor for Organization Management
This commit is contained in:
JingleManSweep
2026-01-12 19:59:32 +00:00
committed by GitHub
4 changed files with 1049 additions and 4 deletions

548
MEMBER_EDITOR_PLAN.md Normal file
View File

@@ -0,0 +1,548 @@
# Member Editor Implementation Plan
## Overview
Create a Member Editor admin interface at `/a/members` following the proven pattern established by the Tag Editor. All backend API infrastructure already exists; this is purely a web UI implementation.
## Current State
### ✅ Already Implemented
| Component | Status | Location |
|-----------|--------|----------|
| Member Model | ✅ Complete | `common/models/member.py` |
| API Schemas | ✅ Complete | `common/schemas/members.py` |
| API CRUD Endpoints | ✅ Complete | `api/routes/members.py` |
| YAML Import | ✅ Complete | `collector/member_import.py` |
| Public Members Page | ✅ Complete | `web/routes/members.py` |
| Admin Foundation | ✅ Complete | `web/routes/admin.py` |
### ❌ Missing - To Be Implemented
1. Admin web routes for Member CRUD at `/a/members`
2. Admin template `admin/members.html`
3. Navigation card in admin index
## Architecture Reference
The Member Editor will follow the **exact same pattern** as the Tag Editor:
```
User visits /a/members
Displays members table with actions
User clicks: Create | Edit | Delete
Modal opens with form
Form submits via POST to /a/members/{action}
Backend calls API endpoint
Redirects back to /a/members with flash message
```
## Implementation Tasks
### Task 1: Add Admin Web Routes
**File:** `src/meshcore_hub/web/routes/admin.py`
Add the following routes following the Tag Editor pattern:
#### 1.1 Main Members Page (GET)
```python
@router.get("/members", response_class=HTMLResponse)
async def admin_members(
request: Request,
message: Optional[str] = Query(None),
error: Optional[str] = Query(None)
) -> HTMLResponse
```
**Responsibilities:**
- Check admin enabled via `_check_admin_enabled(request)`
- Get auth context via `_get_auth_context(request)`
- Fetch all members from `/api/v1/members?limit=1000`
- Sort members by name
- Render `admin/members.html` template
#### 1.2 Create Member (POST)
```python
@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
```
**Responsibilities:**
- Check admin enabled and require auth
- POST to `/api/v1/members` with form data
- Handle success (201) → redirect with success message
- Handle errors (409 duplicate, 400 validation) → redirect with error
- Use `_build_redirect_url()` helper
#### 1.3 Update Member (POST)
```python
@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
```
**Responsibilities:**
- Check admin enabled and require auth
- Build update payload (only non-None fields)
- PUT to `/api/v1/members/{id}` with update data
- Handle success (200) → redirect with success message
- Handle errors (404, 409, 400) → redirect with error
#### 1.4 Delete Member (POST)
```python
@router.post("/members/delete", response_class=RedirectResponse)
async def admin_delete_member(
request: Request,
id: str = Form(...)
) -> RedirectResponse
```
**Responsibilities:**
- Check admin enabled and require auth
- DELETE to `/api/v1/members/{id}`
- Handle success (204) → redirect with success message
- Handle errors (404) → redirect with error
### Task 2: Create Admin Template
**File:** `src/meshcore_hub/web/templates/admin/members.html`
Structure based on `admin/node_tags.html`:
#### 2.1 Page Layout
```html
{% extends "base.html" %}
{% block title %}Members - Admin{% endblock %}
{% block breadcrumb %}
<div class="text-sm breadcrumbs">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/a/">Admin</a></li>
<li>Members</li>
</ul>
</div>
{% endblock %}
{% block content %}
<!-- Flash messages -->
<!-- Members table -->
<!-- Add member form -->
<!-- Modals (Edit, Delete) -->
{% endblock %}
```
#### 2.2 Flash Messages Section
```html
{% if message %}
<div class="alert alert-success">{{ message }}</div>
{% endif %}
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
```
#### 2.3 Members Table Card
```html
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Network Members</h2>
<table class="table table-zebra">
<thead>
<tr>
<th>Member ID</th>
<th>Name</th>
<th>Callsign</th>
<th>Role</th>
<th>Contact</th>
<th>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><code>{{ member.member_id }}</code></td>
<td>{{ member.name }}</td>
<td>
{% if member.callsign %}
<span class="badge badge-primary">{{ member.callsign }}</span>
{% endif %}
</td>
<td>{{ member.role or '-' }}</td>
<td>{{ member.contact or '-' }}</td>
<td>
<button class="btn btn-sm btn-ghost btn-edit">Edit</button>
<button class="btn btn-sm btn-error btn-delete">Delete</button>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center text-gray-500">
No members configured yet
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
```
#### 2.4 Add Member Form Card
```html
<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>
</label>
<input type="text" name="member_id" required
placeholder="walshie86" class="input input-bordered">
<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>
</label>
<input type="text" name="name" required
placeholder="John Smith" class="input input-bordered">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Callsign</span>
</label>
<input type="text" name="callsign"
placeholder="VK4ABC" class="input input-bordered">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Role</span>
</label>
<input type="text" name="role"
placeholder="Network Coordinator" class="input input-bordered">
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text">Contact</span>
</label>
<input type="text" name="contact"
placeholder="john@example.com" class="input input-bordered">
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea name="description" rows="3"
placeholder="Brief description..."
class="textarea textarea-bordered"></textarea>
</div>
</div>
<div class="form-control mt-4">
<button type="submit" class="btn btn-primary">Add Member</button>
</div>
</form>
</div>
</div>
```
#### 2.5 Edit Modal
```html
<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">
<input type="hidden" name="id" id="edit_id">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div class="form-control">
<label class="label">
<span class="label-text">Member ID *</span>
</label>
<input type="text" name="member_id" id="edit_member_id"
required class="input input-bordered">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Name *</span>
</label>
<input type="text" name="name" id="edit_name"
required class="input input-bordered">
</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">
</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">
</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">
</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>
```
#### 2.6 Delete Modal
```html
<dialog id="deleteModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Delete Member</h3>
<p class="py-4">
Are you sure you want to delete member <strong id="delete_member_name"></strong>?
This action cannot be undone.
</p>
<form method="post" action="/a/members/delete">
<input type="hidden" name="id" id="delete_id">
<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>
```
#### 2.7 JavaScript Event Handlers
```html
<script>
// 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>
```
### Task 3: Update Admin Index
**File:** `src/meshcore_hub/web/templates/admin/index.html`
Add a new navigation card for the Member Editor after the existing Node Tags card:
```html
<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 class="card-actions justify-end">
<button class="btn btn-primary btn-sm">Manage</button>
</div>
</div>
</a>
```
## Field Descriptions
### Member Model Fields
| Field | Type | Required | Description | Example |
|-------|------|----------|-------------|---------|
| `member_id` | string | Yes | Unique identifier for member | `walshie86` |
| `name` | string | Yes | Full display name | `John Smith` |
| `callsign` | string | No | Amateur radio callsign | `VK4ABC` |
| `role` | string | No | Member's role in network | `Network Coordinator` |
| `description` | text | No | Longer description of member | `Manages Brisbane nodes` |
| `contact` | string | No | Contact information | `john@example.com` |
### Validation Rules
- `member_id`: 1-50 chars, alphanumeric + underscore only
- `name`: 1-255 chars
- `callsign`: Max 20 chars
- `role`: Max 100 chars
- `contact`: Max 255 chars
- `description`: No limit (TEXT field)
## Testing Checklist
### Manual Testing
- [ ] Access `/a/members` - displays empty state
- [ ] Create new member with all fields
- [ ] Create new member with only required fields
- [ ] Edit member - update single field
- [ ] Edit member - update all fields
- [ ] Delete member - confirm deletion
- [ ] Delete member - cancel deletion
- [ ] Try duplicate member_id - shows error
- [ ] Try empty required fields - shows validation error
- [ ] Verify flash messages appear on success/error
- [ ] Check mobile responsive layout
- [ ] Verify authentication required for POST actions
- [ ] Verify admin disabled shows 404
### API Integration Testing
- [ ] Create via web → verify in API GET
- [ ] Update via web → verify in API GET
- [ ] Delete via web → verify 404 in API GET
- [ ] Check timestamps update correctly
### UI/UX Testing
- [ ] Table sorts properly
- [ ] Modals open/close correctly
- [ ] Form validation works
- [ ] Error messages are clear
- [ ] Success messages are clear
- [ ] Layout works on mobile
- [ ] Layout works on tablet
- [ ] Layout works on desktop
## Acceptance Criteria
✅ The Member Editor is complete when:
1. **Create**: Admin can create new members via form
2. **Read**: Admin can view all members in a table
3. **Update**: Admin can edit member fields via modal
4. **Delete**: Admin can delete members with confirmation
5. **Navigation**: Admin index has working Members card
6. **Authentication**: All state-changing operations require auth
7. **Validation**: Form validation matches API schemas
8. **Error Handling**: Clear error messages for failures
9. **Success Feedback**: Flash messages confirm successful actions
10. **Mobile Responsive**: Works on all screen sizes
## Future Enhancements (Out of Scope)
- Bulk import members from web UI
- Export members to YAML
- Link member to multiple nodes
- Member activity history
- Search/filter members
- Pagination for large member lists
## Implementation Order
1. ✅ Review existing code (Tag Editor + Member API)
2. ⬜ Add admin web routes to `admin.py`
3. ⬜ Create `admin/members.html` template
4. ⬜ Update admin index navigation
5. ⬜ Test CRUD operations
6. ⬜ Test error cases
7. ⬜ Test responsive layout
8. ⬜ Commit and push changes
## Estimated Complexity
- **Routes**: Simple (follow existing pattern)
- **Template**: Medium (adapt from node_tags.html)
- **Testing**: Medium (comprehensive testing required)
**Total Effort**: ~2-3 hours of focused development
## Related Documentation
- Tag Editor Reference: `src/meshcore_hub/web/routes/admin.py` (lines for node_tags routes)
- Tag Editor Template: `src/meshcore_hub/web/templates/admin/node_tags.html`
- Member API: `src/meshcore_hub/api/routes/members.py`
- Member Schemas: `src/meshcore_hub/common/schemas/members.py`
- Member Model: `src/meshcore_hub/common/models/member.py`

View File

@@ -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": 500},
)
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)

View File

@@ -21,7 +21,8 @@
{% if auth_username or auth_user %}
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
{{ auth_username or auth_user }}
</span>
@@ -29,7 +30,8 @@
{% if auth_email %}
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{{ auth_email }}
</span>
@@ -38,11 +40,26 @@
<!-- Navigation Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<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>
<a href="/a/node-tags" 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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
<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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
</svg>
Node Tags
</h2>

View File

@@ -0,0 +1,282 @@
{% 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">
<div class="card-body">
<div class="flex justify-between items-center">
<h2 class="card-title">Network Members ({{ members|length }})</h2>
<button class="btn btn-primary btn-sm" onclick="addModal.showModal()">Add Member</button>
</div>
{% 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>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-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.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">Click "Add Member" to create the first member.</p>
</div>
{% endif %}
</div>
</div>
<!-- Add Modal -->
<dialog id="addModal" class="modal">
<div class="modal-box w-11/12 max-w-2xl">
<h3 class="font-bold text-lg">Add New Member</h3>
<form method="post" action="/a/members" class="py-4">
<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="add_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" id="add_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" id="add_callsign" class="input input-bordered"
placeholder="VK4ABC" maxlength="20">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Contact</span>
</label>
<input type="text" name="contact" id="add_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" id="add_description" rows="3" class="textarea textarea-bordered"
placeholder="Brief description of member's role and responsibilities..."></textarea>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="addModal.close()">Cancel</button>
<button type="submit" class="btn btn-primary">Add Member</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- 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">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_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 %}