From a7985ba06510363570c07b846c2f5f35a6cb1a35 Mon Sep 17 00:00:00 2001 From: Eduardo Silva Date: Mon, 9 Feb 2026 21:59:16 -0300 Subject: [PATCH] Add API key management functionality with views, forms, and templates --- api_v2/forms.py | 71 ++++++++++++ ...port_alter_apikey_allow_reload_and_more.py | 51 +++++++++ api_v2/models.py | 16 +-- api_v2/views.py | 97 ++++++++++++++++- templates/api_v2/api_key_form.html | 41 +++++++ templates/api_v2/list.html | 101 ++++++++++++++++++ templates/template_parts/base_sidebar.html | 10 ++ wireguard_webadmin/urls.py | 4 + 8 files changed, 383 insertions(+), 8 deletions(-) create mode 100644 api_v2/forms.py create mode 100644 api_v2/migrations/0002_alter_apikey_allow_export_alter_apikey_allow_reload_and_more.py create mode 100644 templates/api_v2/api_key_form.html create mode 100644 templates/api_v2/list.html diff --git a/api_v2/forms.py b/api_v2/forms.py new file mode 100644 index 0000000..fc3edd9 --- /dev/null +++ b/api_v2/forms.py @@ -0,0 +1,71 @@ +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Column, HTML, Layout, Row, Submit +from django import forms +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from .models import ApiKey + + +class ApiKeyForm(forms.ModelForm): + class Meta: + model = ApiKey + fields = [ + 'name', + 'allowed_instances', + 'allow_restart', + 'allow_reload', + 'allow_export', + 'enabled', + ] + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop('user', None) + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + self.helper.form_method = 'post' + + back_label = _("Back") + delete_label = _("Delete") + regenerate_label = _("Regenerate Token") + + if self.instance.pk: + delete_url = reverse('api_v2_delete', kwargs={'uuid': self.instance.uuid}) + delete_html = f"{delete_label}" + + regenerate_html = f'' + else: + delete_html = '' + regenerate_html = '' + + self.helper.layout = Layout( + Row( + Column('name', css_class='form-group col-md-12 mb-0'), + css_class='form-row' + ), + Row( + Column('allowed_instances', css_class='form-group col-md-12 mb-0'), + css_class='form-row' + ), + Row( + Column('allow_restart', css_class='form-group col-md-4 mb-0'), + Column('allow_reload', css_class='form-group col-md-4 mb-0'), + Column('allow_export', css_class='form-group col-md-4 mb-0'), + css_class='form-row' + ), + Row( + Column('enabled', css_class='form-group col-md-12 mb-0'), + css_class='form-row' + ), + Row( + Column( + Submit('submit', _('Save'), css_class='btn btn-success'), + HTML(f' {regenerate_html} ') if regenerate_html else HTML(''), + HTML(f' {back_label} '), + HTML(f' {delete_html} '), + css_class='col-md-12' + ), + css_class='form-row' + ) + ) diff --git a/api_v2/migrations/0002_alter_apikey_allow_export_alter_apikey_allow_reload_and_more.py b/api_v2/migrations/0002_alter_apikey_allow_export_alter_apikey_allow_reload_and_more.py new file mode 100644 index 0000000..dae7abf --- /dev/null +++ b/api_v2/migrations/0002_alter_apikey_allow_export_alter_apikey_allow_reload_and_more.py @@ -0,0 +1,51 @@ +# Generated by Django 5.2.11 on 2026-02-10 00:54 + +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api_v2', '0001_initial'), + ('wireguard', '0032_remove_peer_enabled_by_schedule'), + ] + + operations = [ + migrations.AlterField( + model_name='apikey', + name='allow_export', + field=models.BooleanField(default=True, verbose_name='Allow Export'), + ), + migrations.AlterField( + model_name='apikey', + name='allow_reload', + field=models.BooleanField(default=True, verbose_name='Allow Reload'), + ), + migrations.AlterField( + model_name='apikey', + name='allow_restart', + field=models.BooleanField(default=True, verbose_name='Allow Restart'), + ), + migrations.AlterField( + model_name='apikey', + name='allowed_instances', + field=models.ManyToManyField(blank=True, related_name='api_keys', to='wireguard.wireguardinstance', verbose_name='Allowed Instances'), + ), + migrations.AlterField( + model_name='apikey', + name='enabled', + field=models.BooleanField(default=True, verbose_name='Enabled'), + ), + migrations.AlterField( + model_name='apikey', + name='name', + field=models.CharField(max_length=64, unique=True, verbose_name='Name'), + ), + migrations.AlterField( + model_name='apikey', + name='token', + field=models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='Token'), + ), + ] diff --git a/api_v2/models.py b/api_v2/models.py index b32b107..5350f64 100644 --- a/api_v2/models.py +++ b/api_v2/models.py @@ -1,21 +1,23 @@ import uuid from django.db import models +from django.utils.translation import gettext_lazy as _ from wireguard.models import WireGuardInstance class ApiKey(models.Model): - name = models.CharField(max_length=64, unique=True) - token = models.UUIDField(default=uuid.uuid4, unique=True) - allowed_instances = models.ManyToManyField(WireGuardInstance, blank=True, related_name='api_keys') - allow_restart = models.BooleanField(default=True) - allow_reload = models.BooleanField(default=True) - allow_export = models.BooleanField(default=True) - enabled = models.BooleanField(default=True) + name = models.CharField(max_length=64, unique=True, verbose_name=_("Name")) + token = models.UUIDField(default=uuid.uuid4, unique=True, verbose_name=_("Token")) + allowed_instances = models.ManyToManyField(WireGuardInstance, blank=True, related_name='api_keys', verbose_name=_("Allowed Instances")) + allow_restart = models.BooleanField(default=True, verbose_name=_("Allow Restart")) + allow_reload = models.BooleanField(default=True, verbose_name=_("Allow Reload")) + allow_export = models.BooleanField(default=True, verbose_name=_("Allow Export")) + enabled = models.BooleanField(default=True, verbose_name=_("Enabled")) uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) + def __str__(self): return self.name \ No newline at end of file diff --git a/api_v2/views.py b/api_v2/views.py index 60f00ef..e5ee093 100644 --- a/api_v2/views.py +++ b/api_v2/views.py @@ -1 +1,96 @@ -# Create your views here. +import uuid as uuid_lib + +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.translation import gettext_lazy as _ + +from user_manager.models import UserAcl +from .forms import ApiKeyForm +from .models import ApiKey + + +@login_required +def view_api_key_list(request): + if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists(): + return render(request, 'access_denied.html', {'page_title': _('Access Denied')}) + + page_title = _('API Keys') + api_keys = ApiKey.objects.all().order_by('name') + context = {'page_title': page_title, 'api_keys': api_keys} + return render(request, 'api_v2/list.html', context) + +@login_required +def view_manage_api_key(request): + if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists(): + return render(request, 'access_denied.html', {'page_title': _('Access Denied')}) + + api_key = None + if 'uuid' in request.GET: + api_key = get_object_or_404(ApiKey, uuid=request.GET['uuid']) + page_title = _('Edit API Key: ') + api_key.name + + else: + page_title = _('Add API Key') + + if request.method == 'POST': + if api_key: + form = ApiKeyForm(request.POST, instance=api_key, user=request.user) + else: + form = ApiKeyForm(request.POST, user=request.user) + + if request.POST.get('regenerate_token') == 'true' and api_key: + api_key.token = uuid_lib.uuid4() + api_key.save() + messages.success(request, _('Token regenerated successfully.')) + return redirect('/manage_api/v2/list/') + + if form.is_valid(): + form.save() + messages.success(request, _('API Key saved successfully.')) + return redirect('/manage_api/v2/list/') + else: + if api_key: + form = ApiKeyForm(instance=api_key, user=request.user) + else: + form = ApiKeyForm(user=request.user) + + form_description = { + 'size': '', + 'content': _(''' +
API Keys
+

API Keys allow external applications to interact with the WireGuard WebAdmin API.

+

Token: The secret token used for authentication. Keep this secure.

+

Allowed Instances: The WireGuard instances this key can manage. If none are selected, the key has access to ALL instances.

+

Permissions: specific actions allowed for this key.

+ ''') + } + + context = { + 'page_title': page_title, + 'form': form, + 'instance': api_key, + 'form_description': form_description + } + return render(request, 'api_v2/api_key_form.html', context) + + +@login_required +def view_delete_api_key(request, uuid): + if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists(): + return render(request, 'access_denied.html', {'page_title': _('Access Denied')}) + + api_key = get_object_or_404(ApiKey, uuid=uuid) + + if request.method == 'POST': + api_key.delete() + messages.success(request, _('API Key deleted successfully.')) + return redirect('api_v2_list') + + context = { + 'object': api_key, + 'title': _('Delete API Key'), + 'cancel_url': f"/manage_api/v2/manage/?uuid={api_key.uuid}", + 'text': _('Are you sure you want to delete the API Key "%(name)s"?') % {'name': api_key.name} + } + return render(request, 'generic_delete_confirmation.html', context) diff --git a/templates/api_v2/api_key_form.html b/templates/api_v2/api_key_form.html new file mode 100644 index 0000000..9dac29c --- /dev/null +++ b/templates/api_v2/api_key_form.html @@ -0,0 +1,41 @@ +{% extends 'base.html' %} +{% load crispy_forms_tags %} +{% load i18n %} + +{% block content %} +
+
+
+ {% if page_title %} +
+

{{ page_title }}

+
+ {% endif %} + +
+
+ {% csrf_token %} + {% crispy form %} +
+
+
+
+ + {% if form_description %} +
+
+ +
+
+ {{ form_description.content|safe }} +
+
+
+
+ {% endif %} + +
+{% endblock %} + +{% block custom_page_scripts %} +{% endblock %} \ No newline at end of file diff --git a/templates/api_v2/list.html b/templates/api_v2/list.html new file mode 100644 index 0000000..0543369 --- /dev/null +++ b/templates/api_v2/list.html @@ -0,0 +1,101 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} + + + + + + + + + + + + + + + {% for key in api_keys %} + + + + + + + + + + + {% endfor %} + +
{% trans 'Name' %}{% trans 'Token' %}{% trans 'Allowed Instances' %}{% trans 'Enabled' %}
{{ key.name }} +
+ +
+ +
+
+
+ {% for instance in key.allowed_instances.all %} + {{ instance }} + {% empty %} + {% trans 'All' %} + {% endfor %} + + {% if key.enabled %} + + {% else %} + + {% endif %} + + {% if key.allow_restart %} + + {% else %} + + {% endif %} + + {% if key.allow_reload %} + + {% else %} + + {% endif %} + + {% if key.allow_export %} + + {% else %} + + {% endif %} + + + + +
+ +
+ +
+ + + +{% endblock %} \ No newline at end of file diff --git a/templates/template_parts/base_sidebar.html b/templates/template_parts/base_sidebar.html index 24e6edf..982671d 100644 --- a/templates/template_parts/base_sidebar.html +++ b/templates/template_parts/base_sidebar.html @@ -100,6 +100,16 @@ + +