mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-08-21 14:37:16 +00:00
Added User UI
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,5 +5,4 @@ debug.log
|
|||||||
*.pyc
|
*.pyc
|
||||||
staticfiles/
|
staticfiles/
|
||||||
*.__pycache__.*
|
*.__pycache__.*
|
||||||
vpn/migrations/
|
|
||||||
celerybeat-schedule
|
celerybeat-schedule
|
||||||
|
@@ -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:
|
||||||
|
139
vpn/migrations/0001_initial.py
Normal file
139
vpn/migrations/0001_initial.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
46
vpn/migrations/0002_taskexecutionlog.py
Normal file
46
vpn/migrations/0002_taskexecutionlog.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
1
vpn/migrations/__init__.py
Normal file
1
vpn/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Migration package
|
@@ -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>
|
||||||
|
64
vpn/views.py
64
vpn/views.py
@@ -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.'
|
||||||
|
Reference in New Issue
Block a user