BZOD v0.5.0 RC2: multi-user platform, dashboards, analytics, backups and validation

This commit is contained in:
thakares committed 2026-06-19 14:51:38 +05:30
1 parent 743502b183
commit 7dfb8c0f1b
100 files changed
+15588 -309

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
{% extends "user_layout.html" %}
{% block title %}My API Tokens - BZOD{% endblock %}
{% block active_tokens %}active{% endblock %}
{% block header_title %}API Tokens & Automation{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start;">
<!-- Create Token Card -->
<div class="card">
<h3 style="font-size: 1.15rem; margin-bottom: 1rem;">Generate API Token</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 1.25rem; line-height: 1.4;">
API tokens allow you to automate link shortening and landing page creations. Generated tokens are hashed immediately; you will only be shown the raw token once.
</p>
{% if let Some(raw_token) = new_token %}
<div style="background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.2); padding: 1rem; border-radius: 8px; margin-bottom: 1.25rem; word-break: break-all;">
<span style="display: block; font-size: 0.75rem; color: var(--success-color); font-weight: 700; margin-bottom: 0.25rem; text-transform: uppercase;">Raw Token (Copy now!)</span>
<code style="font-size: 1.1rem; color: var(--text-primary); font-family: monospace; font-weight: 600;">{{ raw_token }}</code>
</div>
{% endif %}
<form action="/api-tokens/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn" style="width: 100%;">Generate New Token</button>
</form>
</div>
<!-- Active Tokens List -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Active API Tokens</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Token ID</th>
<th>Created At</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% if tokens.is_empty() %}
<tr>
<td colspan="3" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No active API tokens generated yet.
</td>
</tr>
{% else %}
{% for t in tokens %}
<tr>
<td>{{ t.id }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ t.created_at }}</td>
<td>
<form action="/api-tokens/revoke/{{ t.id }}" method="POST" onsubmit="return confirm('Revoke this API token? Any applications using it will be blocked.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; color: var(--danger-color);">Revoke</button>
</form>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+144
View File
@@ -0,0 +1,144 @@
{% extends "layout.html" %}
{% block title %}Backup Management - BZOD{% endblock %}
{% block active_settings %}active{% endblock %}
{% block header_title %}Database Backups Console{% endblock %}
{% block header_actions %}
<form action="/admin/backups/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn">Create Backup Archive</button>
</form>
{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem; align-items: start;">
<!-- List of Backup Files -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Available Backup Archives</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Archive File</th>
<th>File Size</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if files.is_empty() %}
<tr>
<td colspan="4" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No backup archive files found in backups folder.
</td>
</tr>
{% else %}
{% for f in files %}
<tr>
<td>
<strong style="font-family: monospace;">{{ f.filename }}</strong>
</td>
<td>{{ f.size_str }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ f.created_str }}</td>
<td>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<a href="/admin/backups/download/{{ f.filename }}" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Download</a>
<form action="/admin/backups/delete/{{ f.filename }}" method="POST" style="margin: 0;" onsubmit="return confirm('Delete backup file {{ f.filename }}? This cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Restore Database Panel -->
<div class="card">
<h3 style="font-size: 1.15rem; margin-bottom: 1rem; color: var(--danger-color);">Restore Platform Databases</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 1.25rem; line-height: 1.4;">
To restore the system databases, select a `.tar.gz` backup archive file. Warning: This will overwrite all active user accounts, quotas, links, and analytics data!
</p>
<form action="/admin/backups/restore" method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="backup_file">Upload Backup File (.tar.gz)</label>
<input type="file" id="backup_file" name="backup_file" class="form-input" accept=".tar.gz" required>
</div>
<div class="form-group">
<label for="confirm_text">Type RESTORE to continue</label>
<input type="text" id="confirm_text" name="confirm_text" class="form-input" placeholder="RESTORE" required autocomplete="off">
</div>
<button type="submit" class="btn btn-danger" style="width: 100%;">Upload & Restore Now</button>
</form>
</div>
</div>
<!-- Backup History Log -->
<div class="card" style="padding: 0; overflow: hidden; margin-top: 1.5rem;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Backup Audit History</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Backup File Path</th>
<th>Status</th>
<th>Size</th>
<th>Error Message</th>
</tr>
</thead>
<tbody>
{% if history.is_empty() %}
<tr>
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No backup execution logs found.
</td>
</tr>
{% else %}
{% for h in history %}
<tr>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ h.created_at[0..19].replace("T", " ") }}</td>
<td style="font-family: monospace; font-size: 0.85rem;">{{ h.backup_path }}</td>
<td>
<span class="badge {% if h.status == "success" %}badge-healthy{% else %}badge-dead{% endif %}">
{{ h.status }}
</span>
</td>
<td>{{ h.size_bytes }} B</td>
<td style="font-size: 0.8rem; color: var(--text-secondary);">{{ h.error_message.as_deref().unwrap_or("-") }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+171
View File
@@ -0,0 +1,171 @@
{% extends "layout.html" %}
{% block title %}System Health & Diagnostics - BZOD{% endblock %}
{% block active_status %}active{% endblock %}
{% block header_title %}System Health Dashboard{% endblock %}
{% block content %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; margin-bottom: 1.5rem;">
<!-- DB Health Report -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
Database Structural Health
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Database File</th>
<th>Schema Ver.</th>
<th>Journal Mode</th>
<th>FK State</th>
<th>Integrity Check</th>
</tr>
</thead>
<tbody>
{% for db in db_reports %}
<tr>
<td><strong>{{ db.database }}.db</strong></td>
<td>v{{ db.schema_version }}</td>
<td><span class="badge" style="background-color: rgba(255,255,255,0.05); color: var(--text-secondary);">{{ db.journal_mode }}</span></td>
<td>{% if db.foreign_keys_enabled %}ON{% else %}OFF{% endif %}</td>
<td>
{% if db.integrity_ok %}
<span class="badge badge-healthy">Passed</span>
{% else %}
<span class="badge badge-dead">Corrupt/Failed</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Storage Utilization -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></svg>
Storage Utilization
</h3>
</div>
<div style="padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem;">
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
<span>Total Data Directory Size:</span>
<strong>{{ total_data_size }}</strong>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
<span>System Database size (`system.db`):</span>
<span>{{ system_db_size }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
<span>Users/Quotas Database size (`users.db`):</span>
<span>{{ users_db_size }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
<span>Administration database (`admin.db`):</span>
<span>{{ admin_db_size }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>Standard Tenants Databases (`/users/*`):</span>
<span>{{ tenants_db_size }}</span>
</div>
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
<!-- Job Execution Status -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Maintenance & Reconcile Jobs
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Job Name</th>
<th>Last Run Status</th>
<th>Started At</th>
<th>Error Msg</th>
</tr>
</thead>
<tbody>
{% if job_history.is_empty() %}
<tr>
<td colspan="4" style="text-align: center; color: var(--text-secondary); padding: 2rem;">No job history logs.</td>
</tr>
{% else %}
{% for job in job_history %}
<tr>
<td><strong>{{ job.job_name }}</strong></td>
<td>
<span class="badge {% if job.status == "success" %}badge-healthy{% else %}badge-dead{% endif %}">
{{ job.status }}
</span>
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ job.started_at[0..19].replace("T", " ") }}</td>
<td style="font-size: 0.8rem; color: var(--text-secondary); max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ job.error_message.as_deref().unwrap_or("-") }}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Health Check Diagnostics -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
Diagnostics Checks (URLs/Pages)
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Resource</th>
<th>Status</th>
<th>Code</th>
<th>Checked At</th>
</tr>
</thead>
<tbody>
{% if health_checks.is_empty() %}
<tr>
<td colspan="4" style="text-align: center; color: var(--text-secondary); padding: 2rem;">No resource diagnostics logs.</td>
</tr>
{% else %}
{% for check in health_checks %}
<tr>
<td><span style="font-family: monospace;">{{ check.object_type }}:{{ check.object_id }}</span></td>
<td>
<span class="badge {% if check.is_healthy == 1 %}badge-healthy{% else %}badge-dead{% endif %}">
{% if check.is_healthy == 1 %}healthy{% else %}unhealthy{% endif %}
</span>
</td>
<td>{{ check.status_code.unwrap_or(0) }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ check.checked_at[0..16].replace("T", " ") }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+10
View File
@@ -378,6 +378,7 @@
</div>
<ul class="nav-links">
{% block sidebar_links %}
<li class="{% block active_dashboard %}{% endblock %}">
<a href="/admin/dashboard">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
@@ -396,6 +397,12 @@
Landing Pages
</a>
</li>
<li class="{% block active_users %}{% endblock %}">
<a href="/admin/users">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Users Management
</a>
</li>
<li class="{% block active_settings %}{% endblock %}">
<a href="/admin/settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
@@ -414,8 +421,10 @@
Status
</a>
</li>
{% endblock %}
</ul>
{% block sidebar_footer %}
<div class="sidebar-footer">
<div class="admin-user-info">
<div class="avatar">{{ admin_username[0..1].to_uppercase() }}</div>
@@ -423,6 +432,7 @@
</div>
<a href="/admin/logout" class="logout-btn">Log Out</a>
</div>
{% endblock %}
</div>
<!-- Main Workspace -->
+5 -5
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - BZOD Platform</title>
<title>{{ title }} - BZOD Platform</title>
<style>
:root {
--bg-base: #070a13;
@@ -143,9 +143,9 @@
<div class="login-card">
<div class="logo">
BZOD <span class="logo-dot"></span>
{{ title }} <span class="logo-dot"></span>
</div>
<p class="subtitle">Personal Redirects & Landing Pages</p>
<p class="subtitle">{{ subtitle }}</p>
{% if let Some(err) = error %}
<div class="alert-error">
@@ -153,7 +153,7 @@
</div>
{% endif %}
<form action="/admin/login" method="POST">
<form action="{{ action }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
@@ -166,7 +166,7 @@
<input type="password" id="password" name="password" class="form-input" required autocomplete="current-password">
</div>
<button type="submit" class="btn">Sign In</button>
<button type="submit" class="btn">{{ button_text }}</button>
</form>
</div>
+158
View File
@@ -0,0 +1,158 @@
{% extends "layout.html" %}
{% block title %}Content Moderation - BZOD{% endblock %}
{% block active_moderation %}active{% endblock %}
{% block header_title %}Content Moderation Panel{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start; margin-bottom: 1.5rem;">
<!-- Moderation Form -->
<div class="card">
<h3 style="font-size: 1.15rem; margin-bottom: 1rem;">Moderate a Resource</h3>
<form action="/admin/moderation" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="slug">Resource Slug</label>
<input type="text" id="slug" name="slug" class="form-input" placeholder="e.g. !hello or abcdef" required>
</div>
<div class="form-group">
<label for="action">Moderation Action</label>
<select id="action" name="action" class="form-input">
<option value="active">Activate (Publicly accessible)</option>
<option value="flagged">Flag (Flagged, visible to admins)</option>
<option value="disabled">Disable (Returns 410 Gone)</option>
<option value="deleted">Delete (Release slug completely)</option>
</select>
</div>
<div class="form-group">
<label for="severity">Severity Level</label>
<select id="severity" name="severity" class="form-input">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high" selected>High</option>
<option value="critical">Critical</option>
</select>
</div>
<div class="form-group">
<label for="reason">Reason / Notes</label>
<textarea id="reason" name="reason" class="form-input" rows="3" placeholder="Violation detail, abuse report summary..." required></textarea>
</div>
<button type="submit" class="btn">Apply Action</button>
</form>
</div>
<!-- Active Flags & Disabled Resources -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Currently Flagged or Disabled Content</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Slug</th>
<th>Owner ID</th>
<th>Type</th>
<th>Status</th>
<th>Updated At</th>
</tr>
</thead>
<tbody>
{% if flagged_items.is_empty() %}
<tr>
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No resources are currently flagged or disabled.
</td>
</tr>
{% else %}
{% for item in flagged_items %}
<tr>
<td>
<strong style="color: var(--accent-color);">/{{ item.slug }}</strong>
</td>
<td>{{ item.owner_user_id }}</td>
<td>{{ item.target_type }}</td>
<td>
<span class="badge" style="background-color: {% if item.status == "disabled" %}rgba(239, 68, 68, 0.15){% else %}rgba(245, 158, 11, 0.15){% endif %}; color: {% if item.status == "disabled" %}var(--danger-color){% else %}var(--warning-color){% endif %};">
{{ item.status }}
</span>
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ item.updated_at[0..10] }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Moderation Events Log -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Moderation Action Log</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Operator</th>
<th>Target User</th>
<th>Resource</th>
<th>Action</th>
<th>Severity</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
{% if logs.is_empty() %}
<tr>
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No moderation actions recorded.
</td>
</tr>
{% else %}
{% for log in logs %}
<tr>
<td style="font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap;">{{ log.timestamp[0..19].replace("T", " ") }}</td>
<td><strong>{{ log.admin_username }}</strong></td>
<td>ID: {{ log.target_user_id }}</td>
<td><span style="font-family: monospace;">{{ log.resource_type }}:{{ log.resource_identifier }}</span></td>
<td>
<span class="badge" style="background-color: rgba(99, 102, 241, 0.15); color: #818cf8;">{{ log.action }}</span>
</td>
<td>
<span class="badge" style="background-color: {% if log.severity == "critical" %}rgba(239,68,68,0.2){% else %}rgba(255,255,255,0.05){% endif %}; color: {% if log.severity == "critical" %}var(--danger-color){% else %}var(--text-secondary){% endif %}; font-weight: 700;">
{{ log.severity }}
</span>
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ log.reason }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+107
View File
@@ -0,0 +1,107 @@
{% extends "layout.html" %}
{% block title %}Quota Management - BZOD{% endblock %}
{% block active_users %}active{% endblock %}
{% block header_title %}User Quota Management{% endblock %}
{% block header_actions %}
<form action="/admin/quotas" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="reconcile_all">
<button type="submit" class="btn">Sync & Reconcile All Quotas</button>
</form>
{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div class="card" style="padding: 0; overflow: hidden;">
<div class="table-container">
<table>
<thead>
<tr>
<th>User ID</th>
<th>URLs (Used/Max)</th>
<th>Pages (Used/Max)</th>
<th>API Tokens (Used/Max)</th>
<th>Storage (Used/Max MB)</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if quotas.is_empty() %}
<tr>
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No quotas defined.
</td>
</tr>
{% else %}
{% for q in quotas %}
<tr>
<td>
<a href="/admin/users/{{ q.user_id }}" style="color: var(--text-primary); text-decoration: underline; font-weight: 600;">
User ID: {{ q.user_id }}
</a>
</td>
<td>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span>{{ q.current_urls }} / {{ q.max_urls }}</span>
<div style="background: rgba(255,255,255,0.05); height: 6px; border-radius: 3px; overflow: hidden; width: 120px;">
<div style="background: var(--primary-grad); height: 100%; width: {{ q.urls_pct() }}%;"></div>
</div>
</div>
</td>
<td>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span>{{ q.current_landings }} / {{ q.max_landings }}</span>
<div style="background: rgba(255,255,255,0.05); height: 6px; border-radius: 3px; overflow: hidden; width: 120px;">
<div style="background: var(--primary-grad); height: 100%; width: {{ q.landings_pct() }}%;"></div>
</div>
</div>
</td>
<td>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span>{{ q.current_api_tokens }} / {{ q.max_api_tokens }}</span>
<div style="background: rgba(255,255,255,0.05); height: 6px; border-radius: 3px; overflow: hidden; width: 120px;">
<div style="background: var(--primary-grad); height: 100%; width: {{ q.api_tokens_pct() }}%;"></div>
</div>
</div>
</td>
<td>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span>{{ q.current_storage_mb }} / {{ q.max_storage_mb }} MB</span>
<div style="background: rgba(255,255,255,0.05); height: 6px; border-radius: 3px; overflow: hidden; width: 120px;">
<div style="background: var(--primary-grad); height: 100%; width: {{ q.storage_pct() }}%;"></div>
</div>
</div>
</td>
<td>
<div style="display: flex; gap: 0.5rem;">
<a href="/admin/users/{{ q.user_id }}/edit" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Edit</a>
<form action="/admin/quotas" method="POST" style="margin: 0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="reconcile">
<input type="hidden" name="user_id" value="{{ q.user_id }}">
<button type="submit" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Reconcile</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
{% extends "layout.html" %}
{% block title %}Active Sessions - BZOD{% endblock %}
{% block active_sessions %}active{% endblock %}
{% block header_title %}Session Administration{% endblock %}
{% block header_actions %}
<form action="/admin/sessions/revoke-all" method="POST" onsubmit="return confirm('Revoke ALL active user sessions? This will log out everyone including you.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger">Revoke All Sessions</button>
</form>
{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div class="card" style="padding: 0; overflow: hidden;">
<div class="table-container">
<table>
<thead>
<tr>
<th>User ID</th>
<th>Session ID (Masked)</th>
<th>Created At</th>
<th>Expires At</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% if sessions.is_empty() %}
<tr>
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No active sessions found in the system.
</td>
</tr>
{% else %}
{% for s in sessions %}
<tr>
<td>
<a href="/admin/users/{{ s.user_id }}" style="color: var(--text-primary); text-decoration: underline; font-weight: 600;">
User ID: {{ s.user_id }}
</a>
</td>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{{ s.id[0..6] }}...
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ s.created_at[0..19].replace("T", " ") }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ s.expires_at[0..19].replace("T", " ") }}</td>
<td>
<form action="/admin/sessions/revoke/{{ s.id }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; color: var(--danger-color);">Revoke</button>
</form>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+181
View File
@@ -0,0 +1,181 @@
{% extends "layout.html" %}
{% block title %}Global Slug Namespace - BZOD{% endblock %}
{% block active_slugs %}active{% endblock %}
{% block header_title %}Global Slug Directory{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<!-- Search & Filtering -->
<div class="card">
<form action="/admin/slugs" method="GET" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; align-items: end;">
<div class="form-group" style="margin-bottom: 0;">
<label for="search">Search Slug</label>
<input type="text" id="search" name="search" class="form-input" placeholder="e.g. !hello" value="{% if let Some(s) = search_filter %}{{ s }}{% endif %}">
</div>
<div class="form-group" style="margin-bottom: 0;">
<label for="owner">Owner User ID</label>
<input type="number" id="owner" name="owner" class="form-input" placeholder="e.g. 1" value="{% if let Some(o) = owner_filter %}{{ o }}{% endif %}">
</div>
<div class="form-group" style="margin-bottom: 0;">
<label for="status">Status</label>
<select id="status" name="status" class="form-input">
<option value="" selected>All Statuses</option>
<option value="active" {% if let Some(s) = status_filter %}{% if s == "active" %}selected{% endif %}{% endif %}>active</option>
<option value="flagged" {% if let Some(s) = status_filter %}{% if s == "flagged" %}selected{% endif %}{% endif %}>flagged</option>
<option value="disabled" {% if let Some(s) = status_filter %}{% if s == "disabled" %}selected{% endif %}{% endif %}>disabled</option>
</select>
</div>
<button type="submit" class="btn">Apply Filters</button>
</form>
</div>
<!-- Slugs Directory Table -->
<div class="card" style="padding: 0; overflow: hidden; margin-bottom: 1.5rem;">
<div class="table-container">
<table>
<thead>
<tr>
<th>Slug</th>
<th>Owner (ID)</th>
<th>Resource Type</th>
<th>Resource ID</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if slugs.is_empty() %}
<tr>
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No registered slugs found matching filters.
</td>
</tr>
{% else %}
{% for item in slugs %}
<tr>
<td>
<strong style="color: var(--accent-color); font-family: monospace; font-size: 1.05rem;">/{{ item.slug }}</strong>
</td>
<td>
<a href="/admin/users/{{ item.owner_user_id }}" style="color: var(--text-primary); text-decoration: underline;">
User ID: {{ item.owner_user_id }}
</a>
</td>
<td>{{ item.target_type }}</td>
<td style="font-family: monospace; font-size: 0.85rem;">{{ item.target_id }}</td>
<td>
<span class="badge" style="background-color: {% if item.status == "active" %}rgba(16, 185, 129, 0.15){% else if item.status == "disabled" %}rgba(239, 68, 68, 0.15){% else %}rgba(245, 158, 11, 0.15){% endif %}; color: {% if item.status == "active" %}var(--success-color){% else if item.status == "disabled" %}var(--danger-color){% else %}var(--warning-color){% endif %};">
{{ item.status }}
</span>
</td>
<td>{{ item.created_at[0..10] }}</td>
<td>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<!-- Transfer Form -->
<form action="/admin/slugs/transfer" method="POST" style="display: flex; gap: 0.25rem;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="slug" value="{{ item.slug }}">
<input type="number" name="new_owner_user_id" placeholder="New ID" required style="width: 80px; padding: 0.35rem 0.5rem; font-size: 0.85rem;">
<button type="submit" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Transfer</button>
</form>
<!-- Disable / Enable Form -->
{% if item.status == "active" %}
<form action="/admin/slugs/status" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="slug" value="{{ item.slug }}">
<input type="hidden" name="status" value="disabled">
<button type="submit" class="btn btn-danger" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Disable</button>
</form>
{% else %}
<form action="/admin/slugs/status" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="slug" value="{{ item.slug }}">
<input type="hidden" name="status" value="active">
<button type="submit" class="btn" style="padding: 0.35rem 0.6rem; font-size: 0.85rem; background: rgba(16,185,129,0.1); color: var(--success-color); border: 1px solid rgba(16,185,129,0.2);">Enable</button>
</form>
{% endif %}
<!-- Delete Form -->
<form action="/admin/slugs/delete" method="POST" onsubmit="return confirm('Release slug /{{ item.slug }}? this cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="slug" value="{{ item.slug }}">
<button type="submit" class="btn btn-danger" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Slug History Log -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Slug Ownership History</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Slug</th>
<th>Old Owner</th>
<th>New Owner</th>
<th>Action</th>
<th>Admin</th>
</tr>
</thead>
<tbody>
{% if history.is_empty() %}
<tr>
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No history records logged.
</td>
</tr>
{% else %}
{% for log in history %}
<tr>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ log.timestamp[0..19].replace("T", " ") }}</td>
<td><strong style="font-family: monospace;">/{{ log.slug }}</strong></td>
<td>{% if let Some(old_id) = log.old_owner_user_id %}User ID: {{ old_id }}{% else %}-{% endif %}</td>
<td>{% if let Some(new_id) = log.new_owner_user_id %}User ID: {{ new_id }}{% else %}-{% endif %}</td>
<td>
<span class="badge" style="background-color: rgba(255,255,255,0.05); color: var(--text-secondary);">{{ log.action }}</span>
</td>
<td>
{% if let Some(admin) = log.admin_username %}
{{ admin }}
{% else %}
System
{% endif %}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+99
View File
@@ -0,0 +1,99 @@
{% extends "user_layout.html" %}
{% block title %}My Analytics Dashboard - BZOD{% endblock %}
{% block active_analytics %}active{% endblock %}
{% block header_title %}Analytics Overview{% endblock %}
{% block content %}
<!-- Overview Stats Cards -->
<div class="grid-stats" style="margin-bottom: 2rem;">
<div class="card stat-card">
<span class="stat-label">Total Link Clicks</span>
<span class="stat-val" style="color: #60a5fa;">{{ total_clicks }}</span>
</div>
<div class="card stat-card">
<span class="stat-label">Unique Visitors</span>
<span class="stat-val" style="color: #c084fc;">{{ unique_visitors }}</span>
</div>
<div class="card stat-card">
<span class="stat-label">Direct Traffic</span>
<span class="stat-val" style="color: #34d399;">{{ direct_clicks }}</span>
</div>
<div class="card stat-card">
<span class="stat-label">Referral Traffic</span>
<span class="stat-val" style="color: #f472b6;">{{ referred_clicks }}</span>
</div>
</div>
<!-- Geographic, Referrers, and Browsers Analysis -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
<!-- Referrers -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Referrer Channels (Top Referrers)
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ referrers_chart|safe }}
</div>
</div>
<!-- Browsers -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
Visitor Browsers
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ browsers_chart|safe }}
</div>
</div>
</div>
<!-- Visitor Log Table -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
<h3 style="font-size: 1.15rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Recent Visitor Access Logs
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Slug</th>
<th>IP Address</th>
<th>Browser / Platform</th>
<th>Country</th>
<th>Referrer</th>
</tr>
</thead>
<tbody>
{% if visits.is_empty() %}
<tr>
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No visitor records recorded yet.
</td>
</tr>
{% else %}
{% for visit in visits %}
<tr>
<td style="font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap;">{{ visit.timestamp[0..19].replace("T", " ") }}</td>
<td><strong style="color: var(--accent-color);">/{{ visit.target_id }}</strong></td>
<td style="font-family: monospace; font-size: 0.85rem;">{{ visit.ip_address }}</td>
<td style="font-size: 0.85rem; max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ visit.user_agent }}</td>
<td>{{ visit.country }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ visit.referer }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+71
View File
@@ -0,0 +1,71 @@
{% extends "user_layout.html" %}
{% block title %}Audit Logs - BZOD{% endblock %}
{% block active_audit %}active{% endblock %}
{% block header_title %}Security Audit Logs{% endblock %}
{% block content %}
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
System Action Log
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Action</th>
<th>Operator (User)</th>
<th>Source IP Address</th>
<th>Log Details / Context</th>
</tr>
</thead>
<tbody>
{% if logs.is_empty() %}
<tr>
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No audit records logged yet.
</td>
</tr>
{% else %}
{% for log in logs %}
<tr>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap;">
{{ log.timestamp[0..19].replace("T", " ") }}
</td>
<td>
<span class="badge" style="background-color: rgba(99, 102, 241, 0.15); color: #818cf8; border: 1px solid rgba(99,102,241,0.2);">
{{ log.action }}
</span>
</td>
<td>
<span style="font-weight: 600; color: var(--text-primary);">{{ log.username }}</span>
</td>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{{ log.ip_address.as_deref().unwrap_or("Unknown") }}
</td>
<td style="color: var(--text-secondary); font-size: 0.85rem;">
{% if let Some(obj_type) = log.object_type %}
<span style="font-weight: 500;">Type:</span> {{ obj_type }}
{% endif %}
{% if let Some(obj_id) = log.object_id %}
| <span style="font-weight: 500;">ID:</span> {{ obj_id }}
{% endif %}
{% if let Some(ua) = log.user_agent %}
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.25rem;">UA: {{ ua }}</div>
{% endif %}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+80
View File
@@ -0,0 +1,80 @@
{% extends "user_layout.html" %}
{% block title %}User Dashboard - BZOD{% endblock %}
{% block active_dashboard %}active{% endblock %}
{% block header_title %}Dashboard Overview{% endblock %}
{% block content %}
<!-- Overview Cards -->
<div class="grid-stats">
<div class="card stat-card">
<span class="stat-label">Total Short URLs</span>
<span class="stat-val" style="color: #60a5fa;">{{ total_urls }}</span>
</div>
<div class="card stat-card">
<span class="stat-label">Total Landing Pages</span>
<span class="stat-val" style="color: #c084fc;">{{ total_pages }}</span>
</div>
<div class="card stat-card">
<span class="stat-label">Total Visits / Clicks</span>
<span class="stat-val" style="color: #34d399;">{{ total_clicks }}</span>
</div>
<div class="card stat-card">
<span class="stat-label">Active / Dead Links</span>
<div style="display: flex; align-items: baseline; gap: 0.5rem; margin-top: 0.25rem;">
<span class="stat-val" style="color: #10b981;">{{ active_links }}</span>
<span style="color: var(--text-muted); font-size: 1.25rem;">/</span>
<span style="font-size: 1.25rem; font-weight: 700; color: #ef4444;">{{ dead_links }}</span>
</div>
</div>
</div>
<!-- Main Traffic Chart -->
<div class="card" style="margin-bottom: 2rem;">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
Click Traffic Trend (Last 30 Days)
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ traffic_chart|safe }}
</div>
</div>
<!-- Secondary Charts Grid -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
<!-- Countries -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
Geographic Analysis (Top Countries)
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ countries_chart|safe }}
</div>
</div>
<!-- Referrers -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Referrer Channels (Top Referrers)
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ referrers_chart|safe }}
</div>
</div>
<!-- Browsers -->
<div class="card" style="grid-column: span 1;">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/></svg>
User Agent breakdown (Top Browsers)
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ browsers_chart|safe }}
</div>
</div>
</div>
{% endblock %}
+257
View File
@@ -0,0 +1,257 @@
{% extends "layout.html" %}
{% block title %}User Detail: {{ target_user.username }} - BZOD{% endblock %}
{% block active_users %}active{% endblock %}
{% block header_title %}User Detail: {{ target_user.username }}{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="margin-bottom: 1.5rem;">
<a href="/admin/users" class="btn btn-secondary" style="padding: 0.5rem 1rem;">
&larr; Back to Users List
</a>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; margin-bottom: 1.5rem;">
<!-- Profile & Actions -->
<div class="card">
<h2 style="font-size: 1.25rem; margin-bottom: 1.25rem;">User Profile & Settings</h2>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1.5rem;">
<tr>
<td style="font-weight: 600; color: var(--text-secondary); width: 35%;">User ID</td>
<td>{{ target_user.id }}</td>
</tr>
<tr>
<td style="font-weight: 600; color: var(--text-secondary);">Username</td>
<td><strong>{{ target_user.username }}</strong></td>
</tr>
<tr>
<td style="font-weight: 600; color: var(--text-secondary);">Account Type</td>
<td><span class="badge" style="background-color: rgba(99, 102, 241, 0.15); color: #818cf8;">{{ target_user.account_type }}</span></td>
</tr>
<tr>
<td style="font-weight: 600; color: var(--text-secondary);">Account Status</td>
<td>
<span class="badge" style="background-color: {% if target_user.status == "active" %}rgba(16, 185, 129, 0.15){% else %}rgba(239, 68, 68, 0.15){% endif %}; color: {% if target_user.status == "active" %}var(--success-color){% else %}var(--danger-color){% endif %};">
{{ target_user.status }}
</span>
</td>
</tr>
<tr>
<td style="font-weight: 600; color: var(--text-secondary);">Created At</td>
<td>{{ target_user.created_at }}</td>
</tr>
<tr>
<td style="font-weight: 600; color: var(--text-secondary);">Last Login</td>
<td>
{% if let Some(login) = target_user.last_login %}
{{ login }}
{% else %}
Never
{% endif %}
</td>
</tr>
{% if let Some(meta) = target_user.metadata %}
<tr>
<td style="font-weight: 600; color: var(--text-secondary);">Metadata</td>
<td>{{ meta }}</td>
</tr>
{% endif %}
</table>
<div style="border-top: 1px solid var(--border-color); padding-top: 1.5rem;">
<h3 style="font-size: 1.1rem; margin-bottom: 1rem;">Administrative Actions</h3>
<div style="display: flex; flex-wrap: wrap; gap: 0.75rem;">
<a href="/admin/users/{{ target_user.id }}/edit" class="btn btn-secondary">Edit Settings</a>
{% if target_user.status == "active" %}
<form action="/admin/users/status/{{ target_user.id }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="status" value="disabled">
<button type="submit" class="btn btn-danger" style="background: rgba(239, 68, 68, 0.1); color: var(--danger-color); border: 1px solid rgba(239, 68, 68, 0.2);">
Disable User
</button>
</form>
{% else %}
<form action="/admin/users/status/{{ target_user.id }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="status" value="active">
<button type="submit" class="btn" style="background: rgba(16, 185, 129, 0.1); color: var(--success-color); border: 1px solid rgba(16, 185, 129, 0.2);">
Enable User
</button>
</form>
{% endif %}
<form action="/admin/users/delete/{{ target_user.id }}" method="POST" onsubmit="return confirm('Permanently delete user {{ target_user.username }}? This cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger">Delete Account</button>
</form>
</div>
</div>
<div style="border-top: 1px solid var(--border-color); padding-top: 1.5rem; margin-top: 1.5rem;">
<h3 style="font-size: 1.1rem; margin-bottom: 1rem;">Reset Password</h3>
<form action="/admin/users/password/{{ target_user.id }}" method="POST" style="display: flex; gap: 0.75rem; align-items: end;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group" style="margin-bottom: 0; flex-grow: 1;">
<label for="new_password">New Password</label>
<input type="password" id="new_password" name="new_password" class="form-input" required minlength="8">
</div>
<button type="submit" class="btn">Update</button>
</form>
</div>
</div>
<!-- Quota & Resource Usage -->
<div class="card">
<h2 style="font-size: 1.25rem; margin-bottom: 1.25rem;">Quota & Storage Usage</h2>
<div style="display: flex; flex-direction: column; gap: 1.25rem;">
<div>
<div style="display: flex; justify-content: space-between; font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem;">
<span>Short URLs Created</span>
<span>{{ stats.current_urls }} / {{ stats.max_urls }}</span>
</div>
<div style="background: rgba(255,255,255,0.05); height: 8px; border-radius: 4px; overflow: hidden;">
<div style="background: var(--primary-grad); height: 100%; width: {{ stats.url_pct }}%;"></div>
</div>
</div>
<div>
<div style="display: flex; justify-content: space-between; font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem;">
<span>Landing Pages</span>
<span>{{ stats.current_landings }} / {{ stats.max_landings }}</span>
</div>
<div style="background: rgba(255,255,255,0.05); height: 8px; border-radius: 4px; overflow: hidden;">
<div style="background: var(--primary-grad); height: 100%; width: {{ stats.landing_pct }}%;"></div>
</div>
</div>
<div>
<div style="display: flex; justify-content: space-between; font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem;">
<span>API Tokens</span>
<span>{{ stats.current_api_tokens }} / {{ stats.max_api_tokens }}</span>
</div>
<div style="background: rgba(255,255,255,0.05); height: 8px; border-radius: 4px; overflow: hidden;">
<div style="background: var(--primary-grad); height: 100%; width: {{ stats.token_pct }}%;"></div>
</div>
</div>
<div>
<div style="display: flex; justify-content: space-between; font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem;">
<span>Storage (MB)</span>
<span>{{ stats.current_storage_mb }} / {{ stats.max_storage_mb }}</span>
</div>
<div style="background: rgba(255,255,255,0.05); height: 8px; border-radius: 4px; overflow: hidden;">
<div style="background: var(--primary-grad); height: 100%; width: {{ stats.storage_pct }}%;"></div>
</div>
</div>
<div style="background-color: rgba(255, 255, 255, 0.02); padding: 1rem; border-radius: 8px; border: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
<div>
<strong style="display: block;">Total Clicks/Visits</strong>
<span style="color: var(--text-secondary); font-size: 0.85rem;">Combined visitor traffic</span>
</div>
<span style="font-size: 1.5rem; font-weight: 700; color: var(--accent-color);">{{ stats.total_visits }}</span>
</div>
<form action="/admin/quotas" method="POST" style="margin-top: 0.5rem;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="reconcile">
<input type="hidden" name="user_id" value="{{ target_user.id }}">
<button type="submit" class="btn btn-secondary" style="width: 100%;">Run Quota Reconcile Job</button>
</form>
</div>
</div>
</div>
<!-- Active Sessions & API Tokens -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.1rem;">Active Sessions</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Created</th>
<th>Expires</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% if sessions.is_empty() %}
<tr>
<td colspan="3" style="text-align: center; color: var(--text-secondary); padding: 2rem;">No active sessions.</td>
</tr>
{% else %}
{% for s in sessions %}
<tr>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ s.created_at[0..10] }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ s.expires_at[0..10] }}</td>
<td>
<form action="/admin/sessions/revoke/{{ s.id }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-secondary" style="padding: 0.3rem 0.6rem; font-size: 0.8rem; color: var(--danger-color);">Revoke</button>
</form>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.1rem;">API Tokens</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Created At</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% if tokens.is_empty() %}
<tr>
<td colspan="3" style="text-align: center; color: var(--text-secondary); padding: 2rem;">No API tokens.</td>
</tr>
{% else %}
{% for t in tokens %}
<tr>
<td>{{ t.id }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ t.created_at }}</td>
<td>
<form action="/admin/settings/api-keys/revoke/{{ t.id }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-secondary" style="padding: 0.3rem 0.6rem; font-size: 0.8rem; color: var(--danger-color);">Revoke</button>
</form>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+65
View File
@@ -0,0 +1,65 @@
{% extends "layout.html" %}
{% block title %}Edit User: {{ target_user.username }} - BZOD{% endblock %}
{% block active_users %}active{% endblock %}
{% block header_title %}Edit User: {{ target_user.username }}{% endblock %}
{% block content %}
<div style="margin-bottom: 1.5rem;">
<a href="/admin/users/{{ target_user.id }}" class="btn btn-secondary" style="padding: 0.5rem 1rem;">
&larr; Back to User Detail
</a>
</div>
<div class="card" style="max-width: 600px; margin: 0 auto;">
<form action="/admin/users/{{ target_user.id }}/edit" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<h2 style="font-size: 1.25rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.75rem;">Account Settings</h2>
<div class="form-group">
<label for="account_type">Account Type</label>
<select id="account_type" name="account_type" class="form-input">
<option value="standard" {% if target_user.account_type == "standard" %}selected{% endif %}>Standard</option>
<option value="admin" {% if target_user.account_type == "admin" %}selected{% endif %}>Admin</option>
<option value="organization" {% if target_user.account_type == "organization" %}selected{% endif %}>Organization</option>
<option value="service" {% if target_user.account_type == "service" %}selected{% endif %}>Service</option>
</select>
</div>
<div class="form-group">
<label for="metadata">Metadata / Notes</label>
<input type="text" id="metadata" name="metadata" class="form-input" value="{% if let Some(m) = target_user.metadata %}{{ m }}{% endif %}" placeholder="Department, team, or notes">
</div>
<h2 style="font-size: 1.25rem; margin-top: 2rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.75rem;">User Resource Quotas</h2>
<div class="form-group">
<label for="max_urls">Max Short URLs</label>
<input type="number" id="max_urls" name="max_urls" class="form-input" value="{{ quotas.max_urls }}" required min="1">
</div>
<div class="form-group">
<label for="max_landings">Max Landing Pages</label>
<input type="number" id="max_landings" name="max_landings" class="form-input" value="{{ quotas.max_landings }}" required min="0">
</div>
<div class="form-group">
<label for="max_api_tokens">Max API Tokens</label>
<input type="number" id="max_api_tokens" name="max_api_tokens" class="form-input" value="{{ quotas.max_api_tokens }}" required min="0">
</div>
<div class="form-group">
<label for="max_storage_mb">Max Storage Limit (MB)</label>
<input type="number" id="max_storage_mb" name="max_storage_mb" class="form-input" value="{{ quotas.max_storage_mb }}" required min="1">
</div>
<div style="margin-top: 2rem; display: flex; gap: 0.75rem;">
<button type="submit" class="btn" style="flex-grow: 1;">Save Configuration</button>
<a href="/admin/users/{{ target_user.id }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
{% extends "layout.html" %}
{% block sidebar_links %}
<li class="{% block active_dashboard %}{% endblock %}">
<a href="/user/dashboard">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
Dashboard
</a>
</li>
<li class="{% block active_urls %}{% endblock %}">
<a href="/user/urls">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Short URLs
</a>
</li>
<li class="{% block active_pages %}{% endblock %}">
<a href="/user/pages">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Landing Pages
</a>
</li>
<li class="{% block active_settings %}{% endblock %}">
<a href="/user/settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Settings
</a>
</li>
<li class="{% block active_audit %}{% endblock %}">
<a href="/user/audit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Audit Log
</a>
</li>
<li class="{% block active_status %}{% endblock %}">
<a href="/user/status">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
Status
</a>
</li>
{% endblock %}
{% block sidebar_footer %}
<div class="sidebar-footer">
<div class="admin-user-info">
<div class="avatar">{{ admin_username[0..1].to_uppercase() }}</div>
<span>{{ admin_username }}</span>
</div>
<a href="/logout" class="logout-btn">Log Out</a>
</div>
{% endblock %}
+87
View File
@@ -0,0 +1,87 @@
{% extends "user_layout.html" %}
{% block title %}Landing Page Analytics - /p/{{ page_code }} - BZOD{% endblock %}
{% block active_pages %}active{% endblock %}
{% block header_title %}Landing Page Analytics: /p/{{ page_code }}{% endblock %}
{% block header_actions %}
<a href="/user/pages" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back to Landing Pages
</a>
{% endblock %}
{% block content %}
<!-- Page Details Card -->
<div class="card" style="margin-bottom: 2rem;">
<h3 style="font-size: 1.1rem; margin-bottom: 1rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Landing Page Details
</h3>
<div>
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Page Title</span>
<span style="font-weight: 600; font-size: 1.1rem; color: var(--text-primary);">{{ title }}</span>
</div>
</div>
<!-- Visitor Activity Log -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem; margin: 0;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Visitor Activity Log
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Sr</th>
<th>Timestamp</th>
<th>IP Address</th>
<th>Country</th>
<th>Referrer</th>
<th>Browser</th>
<th>User-Agent</th>
<th>UTM Source</th>
<th>UTM Campaign</th>
</tr>
</thead>
<tbody>
{% if visits.is_empty() %}
<tr>
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No visitor activity available for this landing page.
</td>
</tr>
{% else %}
{% for entry in visits %}
<tr>
<td>{{ entry.sr }}</td>
<td style="font-family: monospace; font-size: 0.85rem; white-space: nowrap;">{{ entry.timestamp }}</td>
<td style="font-family: monospace; font-size: 0.85rem;">{{ entry.ip_address }}</td>
<td>
{% if entry.country == "Unknown" %}
<span style="color: var(--text-muted);">Unknown</span>
{% else %}
{{ entry.country }}
{% endif %}
</td>
<td>{{ entry.referrer }}</td>
<td>{{ entry.browser }}</td>
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; color: var(--text-secondary);" title="{{ entry.user_agent }}">
{{ entry.user_agent }}
</td>
<td>{{ entry.utm_source }}</td>
<td>{{ entry.utm_campaign }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+162
View File
@@ -0,0 +1,162 @@
{% extends "user_layout.html" %}
{% block title %}My Landing Pages - BZOD{% endblock %}
{% block active_pages %}active{% endblock %}
{% block header_title %}My Landing Pages{% endblock %}
{% block content %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create a New Landing Page
</h3>
<form action="/user/pages/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="title">Page Title *</label>
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required>
</div>
<div class="form-group">
<label for="slug">SEO Slug *</label>
<input type="text" id="slug" name="slug" class="form-input" placeholder="e.g. summer-promo" required pattern="[a-zA-Z0-9\-_]+" title="Only alphanumeric characters, dashes, and underscores allowed">
</div>
<div class="form-group">
<label for="code">Short Code (4-Hex, optional)</label>
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
</div>
<div class="form-group">
<label for="custom_slug">Custom Slug (optional)</label>
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !my-page" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
</div>
<div class="form-group">
<label for="state">Publish State</label>
<select id="state" name="state" class="form-input">
<option value="draft">Draft</option>
<option value="published" selected>Published</option>
<option value="archived">Archived</option>
</select>
</div>
<div class="form-group">
<label for="html_content">HTML Content *</label>
<textarea id="html_content" name="html_content" class="form-input" style="height: 260px; font-family: monospace;" placeholder="<!DOCTYPE html>&#10;<html>&#10;<head>&#10; <title>My Page</title>&#10;</head>&#10;<body>&#10; <h1>Hello</h1>&#10;</body>&#10;</html>" required></textarea>
</div>
<button type="submit" class="btn">Save Page</button>
</form>
</div>
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
My Landing Pages
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Page Title</th>
<th>Short Path</th>
<th>SEO Preview Path</th>
<th>Status</th>
<th>Analytics</th>
<th>Created</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% if pages.is_empty() %}
<tr>
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No landing pages created yet.
</td>
</tr>
{% else %}
{% for page in pages %}
<tr>
<td>
<div style="font-weight: 700; color: var(--text-primary);">
{{ page.title }}
</div>
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
UUID: {{ page.id[0..8] }}...
</div>
</td>
<td>
<a href="/p/{{ page.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace;">
/p/{{ page.code }}
</a>
</td>
<td>
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" style="color: var(--text-secondary); text-decoration: none; font-size: 0.85rem; font-family: monospace;">
/p/{{ page.code }}/{{ page.slug }}
</a>
</td>
<td>
<span class="badge badge-{{ page.state }}">
{{ page.state }}
</span>
</td>
<td>
<a href="/user/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
📊 Analytics
</a>
</td>
<td style="font-size: 0.8rem; color: var(--text-secondary);">
{{ page.created_at[0..10] }}
</td>
<td>
<form action="/user/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
Delete
</button>
</form>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
{% if current_page > 1 %}
<a href="/user/pages?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">&lt;&lt; First</a>
<a href="/user/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">&lt; Prev</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">&lt;&lt; First</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">&lt; Prev</span>
{% endif %}
{% for p in visible_pages %}
{% if self.is_current(p) %}
<span class="btn" style="padding: 0.4rem 0.8rem; font-weight: 700;">{{ p }}</span>
{% else %}
<a href="/user/pages?page={{ p }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">{{ p }}</a>
{% endif %}
{% endfor %}
{% if current_page < total_pages %}
<a href="/user/pages?page={{ current_page + 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Next &gt;</a>
<a href="/user/pages?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last &gt;&gt;</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Next &gt;</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Last &gt;&gt;</span>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+65
View File
@@ -0,0 +1,65 @@
{% extends "user_layout.html" %}
{% block title %}My Settings - BZOD{% endblock %}
{% block active_settings %}active{% endblock %}
{% block header_title %}Account Settings{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Change Password
</h3>
<form action="/user/settings/password" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="current_password">Current Password</label>
<input type="password" id="current_password" name="current_password" class="form-input" required autocomplete="current-password">
</div>
<div class="form-group">
<label for="new_password">New Password</label>
<input type="password" id="new_password" name="new_password" class="form-input" required minlength="8" autocomplete="new-password">
</div>
<button type="submit" class="btn">Update Password</button>
</form>
</div>
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Backup & Restore
</h3>
<div style="display: flex; flex-direction: column; gap: 1rem;">
<a class="btn" href="/user/settings/backup">Download Backup</a>
<form action="/user/settings/restore" method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="backup_file">Restore Backup File</label>
<input type="file" id="backup_file" name="backup_file" class="form-input" accept=".tar.gz" required>
</div>
<div class="form-group">
<label for="confirm_text">Type RESTORE to confirm</label>
<input type="text" id="confirm_text" name="confirm_text" class="form-input" required autocomplete="off">
</div>
<button type="submit" class="btn btn-danger">Restore Backup</button>
</form>
</div>
</div>
</div>
{% endblock %}
+161
View File
@@ -0,0 +1,161 @@
{% extends "user_layout.html" %}
{% block title %}System Diagnostics - BZOD{% endblock %}
{% block active_status %}active{% endblock %}
{% block header_title %}Server Status & Diagnostics{% endblock %}
{% block content %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
<!-- Left Column: Core Performance Metrics -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
System Status
</h3>
<div style="display: flex; flex-direction: column; gap: 1.25rem;">
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.03);">
<span style="color: var(--text-secondary);">Application Status</span>
<span class="badge badge-healthy" style="font-size: 0.85rem;">{{ app_status }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.03);">
<span style="color: var(--text-secondary);">Uptime</span>
<span style="font-weight: 600; font-family: monospace;">{{ uptime }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.03);">
<span style="color: var(--text-secondary);">Memory Usage</span>
<span style="font-weight: 600; font-family: monospace;">{{ memory_usage }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.03);">
<span style="color: var(--text-secondary);">Analytics Memory Queue Size</span>
<span style="font-weight: 600; font-family: monospace; color: {% if queue_size > 100 %}var(--warning-color){% else %}var(--success-color){% endif %};">
{{ queue_size }} items
</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.03);">
<span style="color: var(--text-secondary);">Build Version</span>
<span style="font-weight: 600; font-family: monospace;">v{{ version }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: var(--text-secondary);">Git Commit Hash</span>
<span style="font-weight: 600; font-family: monospace; color: var(--text-muted);">{{ git_commit }}</span>
</div>
</div>
</div>
<!-- Right Column: Database Storage info -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22c5.523 0 10-2.239 10-5V5c0-2.761-4.477-5-10-5S2 2.239 2 5v12c0 2.761 4.477 5 10 5z"/><path d="M2 5c0 2.761 4.477 5 10 5s10-2.761 10-5"/><path d="M2 11c0 2.761 4.477 5 10 5s10-2.761 10-5"/></svg>
Database Status
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1.25rem; border: 1px solid rgba(255, 255, 255, 0.03); white-space: pre-wrap; font-family: monospace; font-size: 0.85rem; color: var(--text-secondary); line-height: 1.6;">{{ db_status }}</div>
<p style="font-size: 0.8rem; color: var(--text-muted); margin-top: 1rem; line-height: 1.4;">
All SQLite databases are running with Write-Ahead Logging (WAL) enabled, providing concurrent reads and high transactional throughput.
</p>
</div>
</div>
<!-- Full Width: Link Health Dashboard Section -->
<div class="card" style="margin-top: 1.5rem; padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
Link Health Registry
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Short Link</th>
<th>Destination URL</th>
<th>General Status</th>
<th>Detailed Diagnostic</th>
<th>Latency</th>
<th>Last Checked</th>
</tr>
</thead>
<tbody>
{% if urls.is_empty() %}
<tr>
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No shortened links registered to monitor.
</td>
</tr>
{% else %}
{% for url in urls %}
<tr>
<td>
<a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace; font-size: 0.95rem;">
/{{ url.code }}
</a>
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.1rem;">
{{ url.title.as_deref().unwrap_or("") }}
</div>
</td>
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.85rem; color: var(--text-secondary);">
{{ url.destination }}
</td>
<td>
{% if url.status == "healthy" %}
<span class="badge badge-healthy">Healthy</span>
{% else if url.status == "suspect" %}
<span class="badge badge-suspect">Suspect</span>
{% else %}
<span class="badge badge-dead">Dead</span>
{% endif %}
</td>
<td>
{% if let Some(last_status) = url.last_status %}
{% if last_status == "healthy" %}
<span class="badge" style="background-color: rgba(16, 185, 129, 0.15); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.2);">HTTP OK</span>
{% else if last_status == "timeout" %}
<span class="badge" style="background-color: rgba(245, 158, 11, 0.15); color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.2);">Timeout</span>
{% else if last_status == "dns_failure" %}
<span class="badge" style="background-color: rgba(59, 130, 246, 0.15); color: #3b82f6; border: 1px solid rgba(59, 130, 246, 0.2);">DNS Error</span>
{% else if last_status == "tls_error" %}
<span class="badge" style="background-color: rgba(139, 92, 246, 0.15); color: #8b5cf6; border: 1px solid rgba(139, 92, 246, 0.2);">TLS Error</span>
{% else if last_status == "connection_refused" %}
<span class="badge" style="background-color: rgba(239, 68, 68, 0.15); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.2);">Refused</span>
{% else if last_status == "redirect_loop" %}
<span class="badge" style="background-color: rgba(236, 72, 153, 0.15); color: #ec4899; border: 1px solid rgba(236, 72, 153, 0.2);">Redirect Loop</span>
{% else if last_status.starts_with("http_") %}
<span class="badge" style="background-color: rgba(245, 158, 11, 0.15); color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.2);">{{ last_status.replace("http_", "HTTP ") }}</span>
{% else %}
<span class="badge" style="background-color: rgba(107, 114, 128, 0.15); color: #9ca3af; border: 1px solid rgba(107, 114, 128, 0.2);">{{ last_status }}</span>
{% endif %}
{% else %}
<span style="color: var(--text-muted); font-size: 0.85rem;">Pending</span>
{% endif %}
</td>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{% if let Some(latency) = url.last_latency_ms %}
{{ latency }} ms
{% else %}
-
{% endif %}
</td>
<td style="font-size: 0.8rem; color: var(--text-secondary);">
{{ url.updated_at[0..10] }} {{ url.updated_at[11..16] }}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+89
View File
@@ -0,0 +1,89 @@
{% extends "user_layout.html" %}
{% block title %}Short URL Analytics - /{{ url_code }} - BZOD{% endblock %}
{% block active_urls %}active{% endblock %}
{% block header_title %}URL Analytics: /{{ url_code }}{% endblock %}
{% block header_actions %}
<a href="/user/urls" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back to URLs
</a>
{% endblock %}
{% block content %}
<!-- Link Details Card -->
<div class="card" style="margin-bottom: 2rem;">
<h3 style="font-size: 1.1rem; margin-bottom: 1rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Link Details
</h3>
<div>
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Destination URL</span>
<a href="{{ destination }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-weight: 600; word-break: break-all;">
{{ destination }}
</a>
</div>
</div>
<!-- Visitor Activity Log -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem; margin: 0;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Visitor Activity Log
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Sr</th>
<th>Timestamp</th>
<th>IP Address</th>
<th>Country</th>
<th>Referrer</th>
<th>Browser</th>
<th>User-Agent</th>
<th>UTM Source</th>
<th>UTM Campaign</th>
</tr>
</thead>
<tbody>
{% if visits.is_empty() %}
<tr>
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No visitor activity available for this short link.
</td>
</tr>
{% else %}
{% for entry in visits %}
<tr>
<td>{{ entry.sr }}</td>
<td style="font-family: monospace; font-size: 0.85rem; white-space: nowrap;">{{ entry.timestamp }}</td>
<td style="font-family: monospace; font-size: 0.85rem;">{{ entry.ip_address }}</td>
<td>
{% if entry.country == "Unknown" %}
<span style="color: var(--text-muted);">Unknown</span>
{% else %}
{{ entry.country }}
{% endif %}
</td>
<td>{{ entry.referrer }}</td>
<td>{{ entry.browser }}</td>
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; color: var(--text-secondary);" title="{{ entry.user_agent }}">
{{ entry.user_agent }}
</td>
<td>{{ entry.utm_source }}</td>
<td>{{ entry.utm_campaign }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+145
View File
@@ -0,0 +1,145 @@
{% extends "user_layout.html" %}
{% block title %}My Short URLs - BZOD{% endblock %}
{% block active_urls %}active{% endblock %}
{% block header_title %}My Short URL Library{% endblock %}
{% block content %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start;">
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create a New URL
</h3>
<form action="/user/urls/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="destination">Destination URL *</label>
<input type="url" id="destination" name="destination" class="form-input" placeholder="https://example.com/very-long-path" required>
</div>
<div class="form-group">
<label for="code">Short Code (6-Hex, optional)</label>
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. 4f8c1a (auto-generated if empty)" pattern="[0-9a-fA-F]{6}" title="Must be exactly 6 hex characters (0-9, a-f)">
</div>
<div class="form-group">
<label for="custom_slug">Custom Slug (optional)</label>
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !home" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
</div>
<div class="form-group">
<label for="title">Title (optional)</label>
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. My Blog Post">
</div>
<div class="form-group">
<label for="description">Description (optional)</label>
<textarea id="description" name="description" class="form-input" rows="2" placeholder="Notes or summary..."></textarea>
</div>
<div class="form-group">
<label for="tags">Tags (comma-separated, optional)</label>
<input type="text" id="tags" name="tags" class="form-input" placeholder="e.g. blog, tech, personal">
</div>
<button type="submit" class="btn">Create Link</button>
</form>
</div>
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
My Short Links
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Short Link</th>
<th>Destination</th>
<th>QR Code</th>
<th>Health</th>
<th>Tags</th>
<th>Analytics</th>
<th>Created</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% if urls.is_empty() %}
<tr>
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No short URLs created yet.
</td>
</tr>
{% else %}
{% for url in urls %}
<tr>
<td><a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-family: monospace;">/{{ url.code }}</a></td>
<td style="max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ url.destination }}</td>
<td>
<div style="display: flex; flex-direction: column; align-items: center; gap: 0.25rem;">
<a href="/api/qr/{{ url.code }}.png" target="_blank" title="View QR Code">
<img src="/api/qr/{{ url.code }}.svg" alt="QR" style="width: 32px; height: 32px; border-radius: 4px; border: 1px solid var(--border-color); background: white; padding: 1px;">
</a>
<div style="display: flex; gap: 0.25rem;">
<a href="/api/qr/{{ url.code }}.png" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">PNG</a>
<a href="/api/qr/{{ url.code }}.svg" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">SVG</a>
</div>
</div>
</td>
<td>{{ url.status }}</td>
<td>{% if url.tags.is_empty() %}-{% else %}{{ url.tags.join(", ") }}{% endif %}</td>
<td>
<a href="/user/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; font-size: 0.85rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">View</a>
</td>
<td>{{ url.created_at[0..10] }}</td>
<td>
<form action="/user/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Delete this link?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.75rem;">Delete</button>
</form>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
{% if current_page > 1 %}
<a href="/user/urls?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">&lt;&lt; First</a>
<a href="/user/urls?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">&lt; Prev</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">&lt;&lt; First</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">&lt; Prev</span>
{% endif %}
{% for p in visible_pages %}
{% if self.is_current(p) %}
<span class="btn" style="padding: 0.4rem 0.8rem; font-weight: 700;">{{ p }}</span>
{% else %}
<a href="/user/urls?page={{ p }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">{{ p }}</a>
{% endif %}
{% endfor %}
{% if current_page < total_pages %}
<a href="/user/urls?page={{ current_page + 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Next &gt;</a>
<a href="/user/urls?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last &gt;&gt;</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Next &gt;</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Last &gt;&gt;</span>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+127
View File
@@ -0,0 +1,127 @@
{% extends "layout.html" %}
{% block title %}Users - BZOD{% endblock %}
{% block active_users %}active{% endblock %}
{% block header_title %}Users Management{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div class="card" style="margin-bottom: 1.5rem;">
<h2 style="margin-bottom: 1rem;">Create New User</h2>
<form action="/admin/users/create" method="POST" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; align-items: end;">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" class="form-input" required autocomplete="username">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" class="form-input" required minlength="8" autocomplete="new-password">
</div>
<div class="form-group">
<label for="account_type">Account Type</label>
<select id="account_type" name="account_type" class="form-input">
<option value="standard" selected>Standard</option>
<option value="admin">Admin</option>
<option value="organization">Organization</option>
<option value="service">Service</option>
</select>
</div>
<div class="form-group" style="min-width: 100%;">
<label for="metadata">Metadata (optional)</label>
<input type="text" id="metadata" name="metadata" class="form-input" placeholder="Organization, tags, or notes">
</div>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn" style="grid-column: 1 / -1; width: fit-content;">Create User</button>
</form>
</div>
<div class="card" style="overflow-x: auto;">
<table>
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Status</th>
<th>Type</th>
<th>Last Login</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if users.is_empty() %}
<tr>
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem 0;">
No users found.
</td>
</tr>
{% else %}
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td>
<form action="/admin/users/status/{{ user.id }}" method="POST" style="display: flex; gap: 0.5rem; align-items: center;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<select name="status" class="form-input" style="min-width: 140px;">
<option value="active" {% if user.status == "active" %}selected{% endif %}>active</option>
<option value="disabled" {% if user.status == "disabled" %}selected{% endif %}>disabled</option>
<option value="suspended" {% if user.status == "suspended" %}selected{% endif %}>suspended</option>
<option value="pending" {% if user.status == "pending" %}selected{% endif %}>pending</option>
<option value="deleted" {% if user.status == "deleted" %}selected{% endif %}>deleted</option>
</select>
<button type="submit" class="btn btn-secondary" style="padding: 0.45rem 0.75rem;">Save</button>
</form>
</td>
<td>
<form action="/admin/users/type/{{ user.id }}" method="POST" style="display: grid; gap: 0.4rem;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<select name="account_type" class="form-input" style="min-width: 140px;">
<option value="standard" {% if user.account_type == "standard" %}selected{% endif %}>standard</option>
<option value="admin" {% if user.account_type == "admin" %}selected{% endif %}>admin</option>
<option value="organization" {% if user.account_type == "organization" %}selected{% endif %}>organization</option>
<option value="service" {% if user.account_type == "service" %}selected{% endif %}>service</option>
</select>
<button type="submit" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.8rem;">Save</button>
</form>
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">
{% if let Some(login) = user.last_login %}
{{ login[0..16].replace("T", " ") }}
{% else %}
Never
{% endif %}
</td>
<td>{{ user.created_at[0..10] }}</td>
<td>
<div style="display: grid; gap: 0.5rem;">
<form action="/admin/users/password/{{ user.id }}" method="POST" style="display: grid; gap: 0.4rem;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="password" name="new_password" class="form-input" placeholder="New password" minlength="8" required style="width: 190px;">
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">Reset</button>
</form>
<form action="/admin/users/delete/{{ user.id }}" method="POST" onsubmit="return confirm('Delete user "{{ user.username }}"? This cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
{% extends "layout.html" %}
{% block title %}Create New User - BZOD{% endblock %}
{% block active_users %}active{% endblock %}
{% block header_title %}Create New User{% endblock %}
{% block content %}
<div style="margin-bottom: 1.5rem;">
<a href="/admin/users" class="btn btn-secondary" style="padding: 0.5rem 1rem;">
&larr; Back to Users List
</a>
</div>
<div class="card" style="max-width: 600px; margin: 0 auto;">
<form action="/admin/users/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<h2 style="font-size: 1.25rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.75rem;">User Account Credentials</h2>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" class="form-input" required autocomplete="username" placeholder="e.g. jdoe">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" class="form-input" required minlength="8" autocomplete="new-password">
</div>
<div class="form-group">
<label for="account_type">Account Type</label>
<select id="account_type" name="account_type" class="form-input">
<option value="standard" selected>Standard</option>
<option value="admin">Admin</option>
<option value="organization">Organization</option>
<option value="service">Service</option>
</select>
</div>
<div class="form-group">
<label for="metadata">Metadata / Notes (optional)</label>
<input type="text" id="metadata" name="metadata" class="form-input" placeholder="Department, team, or notes">
</div>
<div style="margin-top: 2rem; display: flex; gap: 0.75rem;">
<button type="submit" class="btn" style="flex-grow: 1;">Create User</button>
<a href="/admin/users" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}