Added User UI

This commit is contained in:
Ultradesu
2025-07-20 23:32:56 +03:00
parent 9bd4896040
commit 2d4c862c5e
7 changed files with 384 additions and 90 deletions

1
.gitignore vendored
View File

@@ -5,5 +5,4 @@ debug.log
*.pyc *.pyc
staticfiles/ staticfiles/
*.__pycache__.* *.__pycache__.*
vpn/migrations/
celerybeat-schedule celerybeat-schedule

View File

@@ -8,9 +8,9 @@ services:
ports: ports:
- "8000:8000" - "8000:8000"
environment: environment:
- POSTGRES_HOST=postgres - POSTGRES_HOST=172.17.0.1
- POSTGRES_USER=postgres - POSTGRES_USER=outfleet
- POSTGRES_PASSWORD=postgres - POSTGRES_PASSWORD='3u!DCZm4%AD$kGij2'
- EXTERNAL_ADDRESS=http://127.0.0.1:8000 - EXTERNAL_ADDRESS=http://127.0.0.1:8000
- CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0
depends_on: depends_on:

View File

@@ -0,0 +1,139 @@
# Initial migration
from django.conf import settings
from django.db import migrations, models
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
import shortuuid
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('comment', models.TextField(blank=True, default='', help_text='Free form user comment')),
('registration_date', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
('last_access', models.DateTimeField(blank=True, null=True)),
('hash', models.CharField(help_text='Random user hash. It\'s using for client config generation.', max_length=64, unique=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='AccessLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user', models.CharField(blank=True, editable=False, max_length=256, null=True)),
('server', models.CharField(blank=True, editable=False, max_length=256, null=True)),
('action', models.CharField(editable=False, max_length=100)),
('data', models.TextField(blank=True, default='', editable=False)),
('timestamp', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='Server',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Server name', max_length=100)),
('comment', models.TextField(blank=True, default='')),
('registration_date', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
('server_type', models.CharField(choices=[('Outline', 'Outline'), ('Wireguard', 'Wireguard')], editable=False, max_length=50)),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
],
options={
'verbose_name': 'Server',
'verbose_name_plural': 'Servers',
'permissions': [('access_server', 'Can view public status')],
'base_manager_name': 'objects',
},
),
migrations.CreateModel(
name='OutlineServer',
fields=[
('server_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vpn.server')),
('admin_url', models.URLField(help_text='Management URL')),
('admin_access_cert', models.CharField(help_text='Fingerprint', max_length=255)),
('client_hostname', models.CharField(help_text='Server address for clients', max_length=255)),
('client_port', models.CharField(help_text='Server port for clients', max_length=5)),
],
options={
'verbose_name': 'Outline',
'verbose_name_plural': 'Outline',
'base_manager_name': 'objects',
},
bases=('vpn.server',),
),
migrations.CreateModel(
name='WireguardServer',
fields=[
('server_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vpn.server')),
('address', models.CharField(max_length=100)),
('port', models.IntegerField()),
('client_private_key', models.CharField(max_length=255)),
('server_publick_key', models.CharField(max_length=255)),
],
options={
'verbose_name': 'Wireguard',
'verbose_name_plural': 'Wireguard',
'base_manager_name': 'objects',
},
bases=('vpn.server',),
),
migrations.CreateModel(
name='ACL',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='vpn.server')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='ACLLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('comment', models.TextField(blank=True, default='', help_text='ACL link comment, device name, etc...')),
('link', models.CharField(blank=True, default='', help_text='Access link to get dynamic configuration', max_length=1024, null=True, unique=True, verbose_name='Access link')),
('acl', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='links', to='vpn.acl')),
],
),
migrations.AddField(
model_name='user',
name='servers',
field=models.ManyToManyField(blank=True, help_text='Servers user has access to', through='vpn.ACL', to='vpn.server'),
),
migrations.AddConstraint(
model_name='acl',
constraint=models.UniqueConstraint(fields=('user', 'server'), name='unique_user_server'),
),
]

View File

@@ -0,0 +1,46 @@
# Generated manually for TaskExecutionLog model
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('vpn', '0001_initial'), # This might need to be adjusted
]
operations = [
migrations.CreateModel(
name='TaskExecutionLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('task_id', models.CharField(help_text='Celery task ID', max_length=255)),
('task_name', models.CharField(help_text='Task name', max_length=100)),
('action', models.CharField(help_text='Action performed', max_length=100)),
('status', models.CharField(choices=[('STARTED', 'Started'), ('SUCCESS', 'Success'), ('FAILURE', 'Failure'), ('RETRY', 'Retry')], default='STARTED', max_length=20)),
('message', models.TextField(help_text='Detailed execution message')),
('execution_time', models.FloatField(blank=True, help_text='Execution time in seconds', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('server', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.server')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vpn.user')),
],
options={
'verbose_name': 'Task Execution Log',
'verbose_name_plural': 'Task Execution Logs',
'ordering': ['-created_at'],
},
),
migrations.AddIndex(
model_name='taskexecutionlog',
index=models.Index(fields=['task_id'], name='vpn_taskexe_task_id_e7f4b1_idx'),
),
migrations.AddIndex(
model_name='taskexecutionlog',
index=models.Index(fields=['created_at'], name='vpn_taskexe_created_c4a9b5_idx'),
),
migrations.AddIndex(
model_name='taskexecutionlog',
index=models.Index(fields=['status'], name='vpn_taskexe_status_1b2c3d_idx'),
),
]

View File

@@ -0,0 +1 @@
# Migration package

View File

@@ -4,7 +4,6 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VPN Access Portal - {{ user.username }}</title> <title>VPN Access Portal - {{ user.username }}</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<style> <style>
* { * {
margin: 0; margin: 0;
@@ -73,6 +72,18 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.stats-info {
margin-top: 15px;
text-align: center;
}
.stats-info p {
color: #6b7280;
font-size: 0.85rem;
margin: 0;
opacity: 0.8;
}
.servers-grid { .servers-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
@@ -97,17 +108,44 @@
.server-header { .server-header {
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
margin-bottom: 20px; margin-bottom: 20px;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 15px;
}
.server-info {
flex: 1;
} }
.server-name { .server-name {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
color: #fff; color: #fff;
margin-bottom: 5px;
}
.server-stats {
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.connection-count {
color: #9ca3af;
font-size: 0.85rem;
background: rgba(74, 222, 128, 0.1);
padding: 4px 8px;
border-radius: 12px;
border: 1px solid rgba(74, 222, 128, 0.2);
transition: all 0.3s ease;
}
.connection-count:hover {
background: rgba(74, 222, 128, 0.2);
border-color: rgba(74, 222, 128, 0.4);
transform: scale(1.05);
} }
.server-type { .server-type {
@@ -118,6 +156,7 @@
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
align-self: flex-start;
} }
.server-status { .server-status {
@@ -172,32 +211,39 @@
} }
.link-header { .link-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px; margin-bottom: 15px;
flex-wrap: wrap; }
gap: 10px;
.link-info {
flex: 1;
} }
.link-comment { .link-comment {
font-weight: 600; font-weight: 600;
color: #fff; color: #fff;
margin-bottom: 5px;
} }
.qr-toggle { .link-stats {
background: rgba(74, 222, 128, 0.2); display: flex;
color: #4ade80; gap: 10px;
border: 1px solid rgba(74, 222, 128, 0.3); flex-wrap: wrap;
padding: 8px 16px; }
border-radius: 8px;
cursor: pointer; .usage-count {
color: #9ca3af;
font-size: 0.8rem; font-size: 0.8rem;
background: rgba(59, 130, 246, 0.1);
padding: 3px 8px;
border-radius: 10px;
border: 1px solid rgba(59, 130, 246, 0.2);
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.qr-toggle:hover { .usage-count:hover {
background: rgba(74, 222, 128, 0.3); background: rgba(59, 130, 246, 0.2);
border-color: rgba(59, 130, 246, 0.4);
transform: scale(1.05);
} }
.link-url { .link-url {
@@ -231,19 +277,6 @@
background: #22c55e; background: #22c55e;
} }
.qr-container {
display: none;
justify-content: center;
padding: 20px;
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
margin-top: 15px;
}
.qr-container.show {
display: flex;
}
.footer { .footer {
text-align: center; text-align: center;
padding: 40px 20px; padding: 40px 20px;
@@ -271,16 +304,29 @@
} }
.stats { .stats {
gap: 20px; gap: 15px;
}
.stat {
min-width: 120px;
} }
.server-card { .server-card {
padding: 20px; padding: 20px;
} }
.link-header { .server-header {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 10px;
}
.server-stats {
gap: 10px;
}
.link-stats {
gap: 8px;
} }
} }
@@ -295,12 +341,6 @@
margin-bottom: 10px; margin-bottom: 10px;
color: #6b7280; color: #6b7280;
} }
.loading {
text-align: center;
padding: 20px;
color: #9ca3af;
}
</style> </style>
</head> </head>
<body> <body>
@@ -315,8 +355,19 @@
</div> </div>
<div class="stat"> <div class="stat">
<span class="stat-number">{{ total_links }}</span> <span class="stat-number">{{ total_links }}</span>
<span class="stat-label">Active Connections</span> <span class="stat-label">Active Links</span>
</div> </div>
<div class="stat">
<span class="stat-number">{{ total_connections }}</span>
<span class="stat-label">Total Uses</span>
</div>
<div class="stat">
<span class="stat-number">{{ recent_connections }}</span>
<span class="stat-label">Last 30 Days</span>
</div>
</div>
<div class="stats-info">
<p>📊 Statistics are updated in real-time and show your connection history</p>
</div> </div>
</div> </div>
@@ -325,7 +376,12 @@
{% for server_name, server_data in servers_data.items %} {% for server_name, server_data in servers_data.items %}
<div class="server-card"> <div class="server-card">
<div class="server-header"> <div class="server-header">
<div class="server-name">{{ server_name }}</div> <div class="server-info">
<div class="server-name">{{ server_name }}</div>
<div class="server-stats">
<span class="connection-count">📊 {{ server_data.total_connections }} uses</span>
</div>
</div>
<div class="server-type">{{ server_data.server_type }}</div> <div class="server-type">{{ server_data.server_type }}</div>
</div> </div>
@@ -347,18 +403,18 @@
{% for link_data in server_data.links %} {% for link_data in server_data.links %}
<div class="link-item"> <div class="link-item">
<div class="link-header"> <div class="link-header">
<div class="link-comment">📱 {{ link_data.comment }}</div> <div class="link-info">
<button class="qr-toggle" onclick="toggleQR('qr-{{ link_data.link.id }}')">Show QR Code</button> <div class="link-comment">📱 {{ link_data.comment }}</div>
<div class="link-stats">
<span class="usage-count">✨ {{ link_data.connections }} uses</span>
</div>
</div>
</div> </div>
<div class="link-url"> <div class="link-url">
{{ link_data.url }} {{ link_data.url }}
<button class="copy-btn" onclick="copyToClipboard('{{ link_data.url }}')">Copy</button> <button class="copy-btn" onclick="copyToClipboard('{{ link_data.url }}')">Copy</button>
</div> </div>
<div id="qr-{{ link_data.link.id }}" class="qr-container">
<!-- QR code will be generated here -->
</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@@ -379,40 +435,6 @@
</div> </div>
<script> <script>
// QR Code generation and management
const qrCodes = {};
function toggleQR(containerId) {
const container = document.getElementById(containerId);
const isVisible = container.classList.contains('show');
if (isVisible) {
container.classList.remove('show');
} else {
container.classList.add('show');
// Generate QR code if not already generated
if (!qrCodes[containerId]) {
const linkElement = container.previousElementSibling;
const linkText = linkElement.textContent.trim().replace('Copy', '').trim();
container.innerHTML = '<div class="loading">Generating QR Code...</div>';
setTimeout(() => {
container.innerHTML = '';
qrCodes[containerId] = new QRCode(container, {
text: linkText,
width: 200,
height: 200,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.M
});
}, 100);
}
}
}
// Copy to clipboard functionality // Copy to clipboard functionality
async function copyToClipboard(text) { async function copyToClipboard(text) {
try { try {
@@ -472,6 +494,11 @@
from { transform: translateX(0); opacity: 1; } from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; } to { transform: translateX(100%); opacity: 0; }
} }
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
`; `;
document.head.appendChild(style); document.head.appendChild(style);
@@ -492,6 +519,36 @@
card.style.transform = 'translateY(0)'; card.style.transform = 'translateY(0)';
}, index * 150); }, index * 150);
}); });
// Animate stat numbers
const statNumbers = document.querySelectorAll('.stat-number');
statNumbers.forEach((stat, index) => {
const finalValue = parseInt(stat.textContent);
if (finalValue > 0) {
stat.textContent = '0';
let current = 0;
const increment = Math.ceil(finalValue / 20);
const timer = setInterval(() => {
current += increment;
if (current >= finalValue) {
stat.textContent = finalValue;
clearInterval(timer);
} else {
stat.textContent = current;
}
}, 50);
}
});
// Add pulse animation to connection counts
setTimeout(() => {
const connectionCounts = document.querySelectorAll('.connection-count, .usage-count');
connectionCounts.forEach((count, index) => {
setTimeout(() => {
count.style.animation = 'pulse 0.6s ease-in-out';
}, index * 100);
});
}, 1000);
}); });
</script> </script>
</body> </body>

View File

@@ -1,7 +1,8 @@
def userPortal(request, user_hash): def userPortal(request, user_hash):
"""HTML portal for user to view their VPN access links and server information""" """HTML portal for user to view their VPN access links and server information"""
from .models import User, ACLLink from .models import User, ACLLink, AccessLog
import logging import logging
from django.db.models import Count, Q
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -10,7 +11,6 @@ def userPortal(request, user_hash):
logger.info(f"User portal accessed for user {user.username}") logger.info(f"User portal accessed for user {user.username}")
except Http404: except Http404:
logger.warning(f"User portal access attempt with invalid hash: {user_hash}") logger.warning(f"User portal access attempt with invalid hash: {user_hash}")
from django.shortcuts import render
return render(request, 'vpn/user_portal_error.html', { return render(request, 'vpn/user_portal_error.html', {
'error_title': 'Access Denied', 'error_title': 'Access Denied',
'error_message': 'Invalid access link. Please contact your administrator.' 'error_message': 'Invalid access link. Please contact your administrator.'
@@ -20,6 +20,45 @@ def userPortal(request, user_hash):
# Get all ACL links for the user with server information # Get all ACL links for the user with server information
acl_links = ACLLink.objects.filter(acl__user=user).select_related('acl__server', 'acl') acl_links = ACLLink.objects.filter(acl__user=user).select_related('acl__server', 'acl')
# Get connection statistics for all user's links
# Count successful connections for each specific link
connection_stats = {}
total_connections = 0
for acl_link in acl_links:
# Count successful connections for this specific link by checking if the link appears in the access log data
# This is more accurate as it counts actual uses of the specific link
link_connections = AccessLog.objects.filter(
user=user.username,
server=acl_link.acl.server.name,
action='Success'
).extra(
where=["data LIKE %s"],
params=[f'%{acl_link.link}%']
).count()
# If no specific link matches found, fall back to general server connection count for this user
if link_connections == 0:
# This gives a rough estimate based on server connections divided by number of links for this server
server_connections = AccessLog.objects.filter(
user=user.username,
server=acl_link.acl.server.name,
action='Success'
).count()
# Get number of links for this server for this user
user_links_on_server = ACLLink.objects.filter(
acl__user=user,
acl__server=acl_link.acl.server
).count()
# Distribute connections evenly among links if we can't track specific usage
if user_links_on_server > 0:
link_connections = server_connections // user_links_on_server
connection_stats[acl_link.link] = link_connections
total_connections += link_connections
# Group links by server # Group links by server
servers_data = {} servers_data = {}
total_links = 0 total_links = 0
@@ -47,32 +86,45 @@ def userPortal(request, user_hash):
'error': server_error, 'error': server_error,
'links': [], 'links': [],
'server_type': server.server_type, 'server_type': server.server_type,
'total_connections': 0,
} }
# Add link information # Add link information with connection stats
link_url = f"{EXTERNAL_ADDRESS}/ss/{link.link}#{server_name}" link_url = f"{EXTERNAL_ADDRESS}/ss/{link.link}#{server_name}"
connection_count = connection_stats.get(link.link, 0)
servers_data[server_name]['links'].append({ servers_data[server_name]['links'].append({
'link': link, 'link': link,
'url': link_url, 'url': link_url,
'qr_data': link_url, # For QR code generation
'comment': link.comment or 'Default', 'comment': link.comment or 'Default',
'connections': connection_count,
}) })
servers_data[server_name]['total_connections'] += connection_count
total_links += 1 total_links += 1
# Get recent connection activity (last 30 days)
from datetime import datetime, timedelta
recent_date = datetime.now() - timedelta(days=30)
recent_connections = AccessLog.objects.filter(
user=user.username,
action='Success',
timestamp__gte=recent_date
).count()
context = { context = {
'user': user, 'user': user,
'servers_data': servers_data, 'servers_data': servers_data,
'total_servers': len(servers_data), 'total_servers': len(servers_data),
'total_links': total_links, 'total_links': total_links,
'total_connections': total_connections,
'recent_connections': recent_connections,
'external_address': EXTERNAL_ADDRESS, 'external_address': EXTERNAL_ADDRESS,
} }
from django.shortcuts import render
return render(request, 'vpn/user_portal.html', context) return render(request, 'vpn/user_portal.html', context)
except Exception as e: except Exception as e:
logger.error(f"Error loading user portal for {user.username}: {e}") logger.error(f"Error loading user portal for {user.username}: {e}")
from django.shortcuts import render
return render(request, 'vpn/user_portal_error.html', { return render(request, 'vpn/user_portal_error.html', {
'error_title': 'Server Error', 'error_title': 'Server Error',
'error_message': 'Unable to load your VPN information. Please try again later or contact support.' 'error_message': 'Unable to load your VPN information. Please try again later or contact support.'