diff --git a/static/admin/css/vpn_admin.css b/static/admin/css/vpn_admin.css
index ccc242d..a9ee975 100644
--- a/static/admin/css/vpn_admin.css
+++ b/static/admin/css/vpn_admin.css
@@ -333,3 +333,10 @@ table.changelist-results td:nth-child(6) {
margin: 0 2px;
display: inline-block;
}
+
+/* Hide xray-subscriptions tab if it appears */
+#xray-subscriptions-tab,
+a[href="#xray-subscriptions-tab"],
+li:has(a[href="#xray-subscriptions-tab"]) {
+ display: none !important;
+}
diff --git a/vpn/admin.py b/vpn/admin.py
index d8db717..24fa403 100644
--- a/vpn/admin.py
+++ b/vpn/admin.py
@@ -928,12 +928,38 @@ class ServerAdmin(PolymorphicParentModelAdmin):
return redirect('admin:vpn_server_changelist')
#admin.site.register(User, UserAdmin)
+# Inline for legacy VPN access (Outline/Wireguard)
+class UserACLInline(admin.TabularInline):
+ model = ACL
+ extra = 0
+ fields = ('server', 'created_at', 'link_count')
+ readonly_fields = ('created_at', 'link_count')
+ verbose_name = "Legacy VPN Server Access"
+ verbose_name_plural = "Legacy VPN Server Access (Outline/Wireguard)"
+
+ def formfield_for_foreignkey(self, db_field, request, **kwargs):
+ if db_field.name == "server":
+ # Only show old-style servers (Outline/Wireguard)
+ kwargs["queryset"] = Server.objects.exclude(server_type='xray_v2')
+ return super().formfield_for_foreignkey(db_field, request, **kwargs)
+
+ @admin.display(description='Links')
+ def link_count(self, obj):
+ count = obj.links.count()
+ return format_html(
+ '{} link(s)',
+ count
+ )
+
+
+
@admin.register(User)
class UserAdmin(admin.ModelAdmin):
form = UserForm
list_display = ('username', 'comment', 'registration_date', 'hash_link', 'server_count')
search_fields = ('username', 'hash')
- readonly_fields = ('hash_link', 'user_statistics_summary')
+ readonly_fields = ('hash_link', 'vpn_access_summary', 'user_statistics_summary')
+ inlines = [] # All VPN access info is now in vpn_access_summary
class Media:
css = {
@@ -945,7 +971,7 @@ class UserAdmin(admin.ModelAdmin):
'fields': ('username', 'first_name', 'last_name', 'email', 'comment')
}),
('Access Information', {
- 'fields': ('hash_link', 'is_active')
+ 'fields': ('hash_link', 'is_active', 'vpn_access_summary')
}),
('Statistics & Server Management', {
'fields': ('user_statistics_summary',),
@@ -953,6 +979,50 @@ class UserAdmin(admin.ModelAdmin):
}),
)
+ @admin.display(description='VPN Access Summary')
+ def vpn_access_summary(self, obj):
+ """Display summary of user's VPN access"""
+ if not obj.pk:
+ return "Save user first to see VPN access"
+
+ # Get legacy VPN access
+ acl_count = ACL.objects.filter(user=obj).count()
+ legacy_links = ACLLink.objects.filter(acl__user=obj).count()
+
+ # Get Xray access
+ from vpn.models_xray import UserSubscription
+ xray_subs = UserSubscription.objects.filter(user=obj, active=True).select_related('subscription_group')
+ xray_groups = [sub.subscription_group.name for sub in xray_subs]
+
+ html = '
'
+
+ # Legacy VPN section
+ html += '
'
+ html += '
đĄ Legacy VPN (Outline/Wireguard)
'
+ if acl_count > 0:
+ html += f'
â Access to {acl_count} server(s)
'
+ html += f'
đ Total links: {legacy_links}
'
+ else:
+ html += '
No legacy VPN access
'
+ html += '
'
+
+ # Xray section
+ html += '
'
+ html += '
đ Xray VPN
'
+ if xray_groups:
+ html += f'
â Active subscriptions: {len(xray_groups)}
'
+ html += '
'
+ for group in xray_groups:
+ html += f'
{group}
'
+ html += '
'
+ else:
+ html += '
No Xray subscriptions
'
+ html += '
'
+
+ html += '
'
+
+ return format_html(html)
+
@admin.display(description='User Portal', ordering='hash')
def hash_link(self, obj):
portal_url = f"{EXTERNAL_ADDRESS}/u/{obj.hash}"
@@ -1016,9 +1086,22 @@ class UserAdmin(admin.ModelAdmin):
links = list(acl.links.all())
# Server header (no slow server status checks)
- type_icon = 'đĩ' if server.server_type == 'outline' else 'đĸ' if server.server_type == 'wireguard' else 'đŖ' if server.server_type == 'xray_core' else ''
+ # Determine server type icon and label
+ if server.server_type == 'Outline':
+ type_icon = 'đĩ'
+ type_label = 'Outline'
+ elif server.server_type == 'Wireguard':
+ type_icon = 'đĸ'
+ type_label = 'Wireguard'
+ elif server.server_type in ['xray_core', 'xray_v2']:
+ type_icon = 'đŖ'
+ type_label = 'Xray'
+ else:
+ type_icon = 'â'
+ type_label = server.server_type
+
html += f'
'
- html += f'
{type_icon} {server.name}
'
+ html += f'
{type_icon} {server.name} ({type_label})
'
# Server stats
server_stat = next((s for s in server_breakdown if s['server_name'] == server.name), None)
@@ -1091,10 +1174,24 @@ class UserAdmin(admin.ModelAdmin):
html += '
â Available Servers
'
html += '
'
for server in unassigned_servers:
- type_icon = 'đĩ' if server.server_type == 'outline' else 'đĸ' if server.server_type == 'wireguard' else 'đŖ' if server.server_type == 'xray_core' else ''
+ # Determine server type icon and label
+ if server.server_type == 'Outline':
+ type_icon = 'đĩ'
+ type_label = 'Outline'
+ elif server.server_type == 'Wireguard':
+ type_icon = 'đĸ'
+ type_label = 'Wireguard'
+ elif server.server_type in ['xray_core', 'xray_v2']:
+ type_icon = 'đŖ'
+ type_label = 'Xray'
+ else:
+ type_icon = 'â'
+ type_label = server.server_type
+
html += f''
html += '
'
@@ -1305,24 +1402,9 @@ class UserAdmin(admin.ModelAdmin):
return super().change_view(request, object_id, form_url, extra_context)
- def save_model(self, request, obj, form, change):
- import logging
- logger = logging.getLogger(__name__)
-
- super().save_model(request, obj, form, change)
- selected_servers = form.cleaned_data.get('servers', [])
-
- # Remove ACLs that are no longer selected
- removed_acls = ACL.objects.filter(user=obj).exclude(server__in=selected_servers)
- for acl in removed_acls:
- logger.info(f"Removing ACL for user {obj.username} from server {acl.server.name}")
- removed_acls.delete()
-
- # Create new ACLs for newly selected servers (with default links)
- for server in selected_servers:
- acl, created = ACL.objects.get_or_create(user=obj, server=server)
- if created:
- logger.info(f"Created new ACL for user {obj.username} on server {server.name}")
+ # Removed save_model as we no longer manage servers directly through the form
+ # Legacy VPN access is now managed through the ACL admin interface
+ # Xray access is managed through the UserXraySubscriptionInline
# Note: get_or_create will use the default save() method which creates default links
@admin.register(AccessLog)
diff --git a/vpn/admin_xray.py b/vpn/admin_xray.py
index fbc85df..96f96a0 100644
--- a/vpn/admin_xray.py
+++ b/vpn/admin_xray.py
@@ -592,79 +592,11 @@ class UserSubscriptionInline(admin.TabularInline):
def add_subscription_management_to_user(UserAdmin):
"""Add subscription management to existing User admin"""
- # Add inline
+ # Add inline only - no fieldset or widget
if hasattr(UserAdmin, 'inlines'):
UserAdmin.inlines = list(UserAdmin.inlines) + [UserSubscriptionInline]
else:
UserAdmin.inlines = [UserSubscriptionInline]
-
- # Add custom fields to fieldsets
- original_fieldsets = list(UserAdmin.fieldsets)
-
- # Find where to insert our fieldset
- insert_index = len(original_fieldsets)
- for i, (title, fields_dict) in enumerate(original_fieldsets):
- if title and 'Statistics' in title:
- insert_index = i + 1
- break
-
- # Insert our fieldset
- subscription_fieldset = (
- 'Xray Subscriptions', {
- 'fields': ('subscription_groups_widget',),
- 'classes': ('wide',)
- }
- )
- original_fieldsets.insert(insert_index, subscription_fieldset)
- UserAdmin.fieldsets = tuple(original_fieldsets)
-
- # Add readonly field
- if hasattr(UserAdmin, 'readonly_fields'):
- UserAdmin.readonly_fields = list(UserAdmin.readonly_fields) + ['subscription_groups_widget']
- else:
- UserAdmin.readonly_fields = ['subscription_groups_widget']
-
- # Add method for displaying subscription groups
- def subscription_groups_widget(self, obj):
- """Display subscription groups management widget"""
- if not obj or not obj.pk:
- return mark_safe('
Save user first to manage subscriptions
')
-
- # Get all groups and user's current subscriptions
- all_groups = SubscriptionGroup.objects.filter(is_active=True)
- user_groups = obj.xray_subscriptions.filter(active=True).values_list('subscription_group_id', flat=True)
-
- html = '
'
- html += '
Available Subscription Groups:
'
-
- if all_groups:
- html += '
'
- for group in all_groups:
- checked = 'checked' if group.id in user_groups else ''
- status = 'â ' if group.id in user_groups else 'âŦ'
-
- html += f'''
-
- {status}
-
-
- '''
- html += '
'
- html += '
'
- html += 'âšī¸ Use the inline form below to manage subscriptions'
- html += '
'
- else:
- html += '
No active subscription groups available
'
-
- html += '
'
- return mark_safe(html)
-
- subscription_groups_widget.short_description = 'Subscription Groups Overview'
- UserAdmin.subscription_groups_widget = subscription_groups_widget
# UserSubscription admin will be integrated into unified Subscriptions admin
diff --git a/vpn/forms.py b/vpn/forms.py
index c27d3f1..0e7300a 100644
--- a/vpn/forms.py
+++ b/vpn/forms.py
@@ -1,14 +1,7 @@
from django import forms
from .models import User
-from .server_plugins import Server
class UserForm(forms.ModelForm):
- servers = forms.ModelMultipleChoiceField(
- queryset=Server.objects.all(),
- widget=forms.CheckboxSelectMultiple,
- required=False
- )
-
class Meta:
model = User
- fields = ['username', 'comment', 'servers']
+ fields = ['username', 'first_name', 'last_name', 'email', 'comment', 'is_active']
diff --git a/vpn/views.py b/vpn/views.py
index 301848b..a6f2725 100644
--- a/vpn/views.py
+++ b/vpn/views.py
@@ -438,8 +438,23 @@ def xray_subscription(request, user_hash):
# Return with proper headers for subscription
response = HttpResponse(subscription_b64, content_type="text/plain; charset=utf-8")
- response['Content-Disposition'] = f'attachment; filename="{user.username}_xray_subscription.txt"'
+ response['Content-Disposition'] = f'attachment; filename="{user.username}"'
response['Cache-Control'] = 'no-cache'
+
+ # Add subscription-specific headers like other providers
+ import base64 as b64
+ profile_title_b64 = b64.b64encode("OutFleet VPN".encode('utf-8')).decode('utf-8')
+ response['profile-title'] = f'base64:{profile_title_b64}'
+ response['profile-update-interval'] = '24' # Update every 24 hours
+ response['profile-web-page-url'] = f'https://{request.get_host()}/u/{user_hash}'
+ response['support-url'] = f'https://{request.get_host()}/admin/'
+
+ # Add user info without limits (unlimited service)
+ # Set very high limits to indicate "unlimited"
+ import time
+ expire_timestamp = int(time.time()) + (365 * 24 * 60 * 60) # 1 year from now
+ response['subscription-userinfo'] = f'upload=0; download=0; total=1099511627776; expire={expire_timestamp}'
+
return response
except Exception as e:
@@ -540,8 +555,8 @@ def generate_xray_connection_string(user, inbound, server_name=None, server_host
# VLESS URL format: vless://uuid@host:port?params#name
params = []
- if inbound.network != 'tcp':
- params.append(f"type={inbound.network}")
+ # Always add transport type for VLESS
+ params.append(f"type={inbound.network}")
if inbound.security != 'none':
params.append(f"security={inbound.security}")