users: Add support registering, editing, and deleting passkeys

Tests:

- Setup: add domain name mystable.example. Add an entry in /etc/hosts on the
  test machine. In Firefox, in about:config, set
  'security.webauthn.allow_with_certificate_override' to 'true'.
- Registration
  - Passkey successful registration:
    - After passkey registration, created time is time at which key is created.
    - After passkey registration, domain is the domain with which the interface
      is accessed at the time of addition of passkey.
    - After passkey registration, Added and Last Used columns show the current
      time in UTC. Signature counter and extensions and aaguid values in the DB
      are as expected.
    - First key's name is 'Key 1'. After that it is 'Key 2' and so on. If a key
      is renamed as 'Key 4', then next key will be named 'Key 5'.
    - Registering passkeys using testing container stable container works.
  - Links:
    - 'Manage passkeys' link is show in the user menu in navbar in both desktop
      mode and mobile mode. Clicking on it redirects the browser to current
      user's passkey management page.
    - User's edit page shows 'Use passkeys for better security'. Clicking on the
      link redirects the browser to passkey management page for the user who's
      account is being edited.
  - Listing:
    - All passkeys are show properly. Name, domain, added, last used, and
      operations show correctly.
    - When using a browser without Javascript script shows an error alert.
    - If not passkeys are present "No passkeys added to user account." message
      is shown.
  - Editing the passkey shows correct page. Title, heading, form labels, form
    value, and buttons are as expected. After editing, passkey is updated
    properly.
  - Deleting the passkey shows a model dialog with correct details. After
    confirmation, passkey is removed and page is refreshed.
  - Error handling:
    - On GNOME's Web, clicking the 'Add Passkey' shows the error 'Browser does
      not support passkeys'.
    - On Chromium, clicking the 'Add passkey' shows the error 'NotAllowedError:
      WebAuthn is not supported on sites with TLS certificate errors.'
    - Raising an error in passkey_add_begin() results in correct error message
      shown with 'Add passkey' button is clicked. Status code is 500.
    - Raising an error in passkey_add_complete() results in correct error
      message shown after unlocking the hardware token. Status code is 500.
    - Canceling the PIN dialog results in '...user denied permission' error
      alert.
    - Canceling the touch dialog results in '...user denied permission' error
      alert.
    - Multiple failed attempts result in multiple alerts being shown at the same
      time.
  - Editing another user's passkeys:
    - Listing passkeys show correct list of passkeys for the user account being
      managed.
    - Adding passkeys adds correctly to the user account being managed.
    - Editing passkey correctly edits passkey of the user account being managed.
      Redirect happens to the correct page after.
    - Deleting passkey correctly edits passkey of the user account being
      managed. Redirect happens to the correct page after.
    - If a non-admin user tries to access passkeys list/edit/delete URL of
      another user, 403 Forbidden error is raised

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2026-03-02 16:44:20 -08:00 committed by James Valleroy
parent fa22ecaa36
commit 1a8868f0cd
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
10 changed files with 680 additions and 3 deletions

View File

@ -0,0 +1,46 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# Generated by Django 4.2.28 on 2026-03-19 23:52
#
"""
Django migration for adding the user's passkey model.
"""
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('plinth', '0005_storednotification'),
]
operations = [
migrations.CreateModel(
name='UserPasskey',
fields=[
('id',
models.AutoField(auto_created=True, primary_key=True,
serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=256, null=True,
blank=True)),
('domain', models.CharField(max_length=256)),
('created_time', models.DateTimeField(auto_now_add=True)),
('last_used_time', models.DateTimeField(auto_now=True)),
('signature_counter', models.IntegerField(default=0)),
('registration_flags', models.IntegerField()),
('extensions', models.JSONField(null=True, blank=True)),
('aaguid', models.UUIDField(null=True, blank=True)),
('credential_id', models.BinaryField(unique=True)),
('public_key', models.BinaryField()),
('user',
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
related_name='passkeys',
to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -44,6 +44,28 @@ class UserProfile(models.Model):
language = models.CharField(max_length=32, null=True, default=None)
class UserPasskey(models.Model):
"""Model to store passkeys for a user account."""
user = models.ForeignKey(settings.AUTH_USER_MODEL,
on_delete=models.CASCADE, related_name='passkeys')
# Relying Party information
name = models.CharField(max_length=256, null=True, blank=True)
domain = models.CharField(max_length=256)
created_time = models.DateTimeField(auto_now_add=True)
last_used_time = models.DateTimeField(auto_now=True)
# Authenticator data
signature_counter = models.IntegerField(default=0)
registration_flags = models.IntegerField()
extensions = models.JSONField(null=True, blank=True)
# Credential data
aaguid = models.UUIDField(null=True, blank=True)
credential_id = models.BinaryField(unique=True)
public_key = models.BinaryField()
@receiver(models.signals.post_save, sender=User)
def _on_user_post_save(sender, instance, **kwargs):
"""When the user model is saved, user profile too."""

View File

@ -0,0 +1,207 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* @licstart The following is the entire license notice for the JavaScript
* code in this page.
*
* This file is part of FreedomBox.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @licend The above is the entire license notice for the JavaScript code
* in this page.
*/
/*
* Decode a given base64 encoded (in web-mode) string to a binary array.
*/
function base64WebDecode(base64WebString) {
let base64String = base64WebString
.replaceAll('-', '+')
.replaceAll('_', '/');
const padding = base64String.length % 4;
if (padding != 0) {
base64String += '='.repeat(4 - padding);
}
const binaryString = atob(base64String);
const binaryArray = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
binaryArray[i] = binaryString.charCodeAt(i);
}
return binaryArray;
}
/*
* Show an error as an bootstrap alert message and print to browser debug
* console.
*/
function handleError(error_string, exception) {
console.log(error_string, exception);
const template = document.getElementById('passkey-message-template');
template.querySelector('.message').innerText = exception.toString();
const messages = document.getElementById('passkey-messages');
messages.insertAdjacentHTML('beforeEnd', template.innerHTML);
}
/*
* Make a window.fetch() request and handle some common errors.
*/
async function jsonFetch(relative_url, options, operation) {
let response, json = null;
const consoleError = 'Could not perform operation: ' + operation;
try {
const url = new URL(relative_url, window.location.href);
response = await window.fetch(url, options);
json = await response.json();
} catch (error) {
handleError(consoleError, error);
return null;
}
if (response.ok && json) {
return json;
}
if (json && json['error_string']) {
handleError(consoleError, json['error_string']);
} else {
handleError(consoleError, `${response.status}: ${response.statusText}`);
}
return null;
}
/*
* Add a passkey. First send a request to the server to begin passkey creation
* and get challenge and creation options. Then request the browser to talk to
* the authenticator to create a passkey. Finally, pass the public key of the
* newly create passkey along with creation results to the server.
*/
async function addPasskey(csrfToken) {
console.log('Adding passkey');
if (!window.PublicKeyCredential) {
const message = document.getElementById(
'browser-does-not-support-passkeys').innerText.trim();
handleError('Browser does not support passkeys', message);
return;
}
//
// Request challenge and options from server.
//
let options = await jsonFetch('add-begin/', {
'method': 'POST',
body: new URLSearchParams({'csrfmiddlewaretoken': csrfToken})
}, 'initiate passkey registration');
if (!options) {
return;
}
options['publicKey']['user']['id'] = base64WebDecode(
options['publicKey']['user']['id']);
options['publicKey']['challenge'] = base64WebDecode(
options['publicKey']['challenge']);
//
// Create new key pair on the authenticator (via the browser).
//
let credential;
try {
credential = await navigator.credentials.create(
{'publicKey': options['publicKey']});
} catch (error) {
handleError('Passkey registration failed.', error);
return;
}
//
// Send the public key and authenticator response to the server for
// verification and storage.
//
let completeResponse = await jsonFetch('add-complete/', {
'method': 'POST',
'headers': {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
'body': JSON.stringify(credential),
}, 'passkey registration');
if (!completeResponse) {
return;
}
console.log('Passkey registration succeeded.');
window.location.reload();
};
/*
* Show a confirmation dialog to the user to delete a passkey.
*/
function onPasskeyDeleteClicked(event) {
const modalElement = document.getElementById('passkey-delete-confirm-dialog');
const modal = new bootstrap.Modal(modalElement);
const passkeyElement = event.target.closest('.passkey');
const nameElements = modalElement.querySelectorAll('.passkey-name');
nameElements.forEach((element) => {
element.innerText = passkeyElement.dataset.passkeyName;
});
modalElement.dataset.passkeyId = passkeyElement.dataset.passkeyId;
event.preventDefault();
modal.show();
}
/*
* Send request to the server to delete a passkey by submitting a form (and
* refreshing the page).
*/
function onPasskeyDeleteConfirmed(event) {
const modelElement = document.getElementById('passkey-delete-confirm-dialog');
const passkeyId = modelElement.dataset.passkeyId;
const form = document.querySelector(
`[data-passkey-id="${passkeyId}"] .form-passkey-delete`);
form.submit();
}
/*
* Attach event handler to 'Add Passkey', 'Delete Passkey', and 'Confirm Delete'
* buttons. Retrieve CSRF token and pass it along.
*/
document.addEventListener('DOMContentLoaded', () => {
const addPasskeyButton = document.getElementById('add-passkey');
if (!addPasskeyButton) {
// Not part of the Manage Passkeys page.
return;
}
const csrfToken = document.getElementsByName('csrfmiddlewaretoken')[0].value;
addPasskeyButton.addEventListener('click', async (event) => {
event.preventDefault();
await addPasskey(csrfToken);
});
const deleteButtons = document.querySelectorAll(
'.passkey-delete-button');
deleteButtons.forEach((element) => {
element.addEventListener('click', onPasskeyDeleteClicked);
});
const confirmDeleteButton = document.querySelector(
'#passkey-delete-confirm-dialog .confirm');
confirmDeleteButton.addEventListener('click', onPasskeyDeleteConfirmed);
});

View File

@ -0,0 +1,7 @@
/*
# SPDX-License-Identifier: AGPL-3.0-or-later
*/
.operations form {
display: inline;
}

View File

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
{% load bootstrap %}
{% load i18n %}
{% block content %}
<h2>{{ title }}</h2>
<form class="form form-edit-passkey" method="post">
{% csrf_token %}
{{ form|bootstrap }}
<input type="submit" class="btn btn-primary"
value="{% trans "Update Passkey" %}"/>
</form>
{% endblock %}

View File

@ -0,0 +1,157 @@
{% extends "base.html" %}
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
{% load static %}
{% load i18n %}
{% load extras %}
{% block page_js %}
<script type="text/javascript" src="{% static 'users/passkeys.js' %}" defer></script>
{% endblock %}
{% block page_head %}
<link type="text/css" rel="stylesheet" href="{% static 'users/users.css' %}">
{% endblock %}
{% block content %}
<div id="passkey-message-template" class="d-none">
<div class="alert alert-danger alert-dismissible
d-flex align-items-center fade show"
role="alert">
<div class="me-2">
{% icon 'exclamation-triangle' %}
<span class="visually-hidden">{% trans "Error:" %}</span>
</div>
<div>
{% trans "Adding passkey failed: " %}
<span class="message"></span>
<button type="button" class="btn-close" data-bs-dismiss="alert"
aria-label="{% trans "Close" %}">
</button>
</div>
</div>
</div>
<div id="localized-strings" class="d-none">
<div id="browser-does-not-support-passkeys">
{% trans "Browser does not support passkeys." %}
</div>
</div>
<div id="passkey-messages">
</div>
<h2>{% trans "Passkeys" %}</h2>
<noscript>
<div class="alert alert-danger d-flex align-items-center" role="alert">
<div class="me-2">
<span class="fa fa-exclamation-triangle" aria-hidden="true"></span>
<span class="visually-hidden">{% trans "Error:" %}</span>
</div>
<div>
{% blocktrans trimmed %}
Working with passkeys requires using browser's Javascript API. Please
enable Javascript support in your browser to continue.
{% endblocktrans %}
</div>
</div>
</noscript>
<p>
{% blocktrans trimmed %}
Passkeys are way to verify user's identity using digital signatures.
They are a more secure alternative to passwords. Secret information is
kept with the user on their phone, laptop, or a hardware token and
unlocked using a PIN, fingerprint, or face ID. No secrets are stored on
the server. The server knows only the public information that can used to
verify user's signatures.
{% endblocktrans %}
</p>
<p>
{% csrf_token %}
<a id="add-passkey" href="#" class="btn btn-primary" role="button"
title="{% trans 'Add passkey' %}">
{% icon "plus" %}
{% trans 'Add passkey' %}
</a>
</p>
{% if object_list %}
<div class="table-responsive">
<table class="table">
<thead>
<th>{% trans "Name" %}</th>
<th>{% trans "For Domain" %}</th>
<th>{% trans "Added" %}</th>
<th>{% trans "Last Used" %}</th>
<th>{% trans "Actions" %}</th>
</thead>
<tbody>
{% for passkey in object_list %}
<tr class="passkey" data-passkey-name="{{ passkey.name }}"
data-passkey-id="{{ passkey.id }}">
<td>{{ passkey.name }}</td>
<td>{{ passkey.domain }}</td>
<td>{{ passkey.created_time }}</td>
<td>{{ passkey.last_used_time }}</td>
<td class="operations">
<a class="btn btn-sm btn-default" role="button"
href="{% url 'users:passkey_edit' passkey.user.username passkey.id %}">
{% icon "pencil-square-o" %}
</a>
<form class="form form-passkey-delete" method="post"
action="{% url 'users:passkey_delete' passkey.user.username passkey.id %}">
{% csrf_token %}
<button class="btn btn-sm btn-default passkey-delete-button"
type="submit">
{% icon "trash-o" %}
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>{% trans "No passkeys added to user account." %}
{% endif %}
<div id="passkey-delete-confirm-dialog" class="modal" tabindex="-1"
role="dialog">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{% blocktrans trimmed with username=object.username %}
Delete passkey <em class="passkey-name"></em>?
{% endblocktrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"
aria-label="{% trans 'Close' %}">
</button>
</div>
<div class="modal-body">
<p>
{% blocktrans trimmed %}
You will need this passkey's device to add it back again.
{% endblocktrans %}
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger confirm">
{% trans "Delete passkey" %}
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{% trans "Cancel" %}
</button>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -19,6 +19,11 @@
</h3>
<p>
{% url 'users:passkeys' object.username as passkeys_url %}
{% blocktrans trimmed %}
Use <a href="{{ passkeys_url }}">passkeys</a> for better security.
{% endblocktrans %}
{% url 'users:change_password' object.username as change_password_url %}
{% blocktrans trimmed %}

View File

@ -18,6 +18,21 @@ urlpatterns = [
re_path(r'^sys/users/(?P<slug>[\w.@+-]+)/change_password/$',
non_admin_view(views.UserChangePassword.as_view()),
name='change_password'),
re_path(r'^sys/users/(?P<username>[\w.@+-]+)/passkeys/$',
non_admin_view(views.PasskeysList.as_view()), name='passkeys'),
re_path(r'^sys/users/(?P<username>[\w.@+-]+)/passkeys/add-begin/$',
non_admin_view(views.passkey_add_begin), name='passkey_add_begin'),
re_path(r'^sys/users/(?P<username>[\w.@+-]+)/passkeys/add-complete/$',
non_admin_view(views.passkey_add_complete),
name='passkey_add_complete'),
re_path(
r'^sys/users/(?P<username>[\w.@+-]+)/passkeys/'
r'(?P<passkey_id>[\d]+)/edit/$',
non_admin_view(views.PasskeyEdit.as_view()), name='passkey_edit'),
re_path(
r'^sys/users/(?P<username>[\w.@+-]+)/passkeys/'
r'(?P<passkey_id>[\d]+)/delete/$',
non_admin_view(views.PasskeyDelete.as_view()), name='passkey_delete'),
re_path(r'^accounts/login/$', public(views.LoginView.as_view()),
name='login'),
re_path(r'^accounts/logout/$', public(views.logout), name='logout'),

View File

@ -1,8 +1,17 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Django views for user app."""
import functools
import importlib.metadata
import json
import logging
import re
import uuid
import axes.utils
import django.views.generic
import fido2.cbor
import fido2.features
from django import shortcuts
from django.contrib import messages
from django.contrib.auth import logout as auth_logout
@ -11,23 +20,36 @@ from django.contrib.auth.models import User
from django.contrib.auth.views import LoginView as DjangoLoginView
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseRedirect
from django.db import IntegrityError
from django.http import HttpResponseRedirect, JsonResponse
from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy
from django.views.decorators.http import require_POST
from django.views.generic.edit import CreateView, FormView, UpdateView
from django.views.generic.edit import (CreateView, DeleteView, FormView,
UpdateView)
from fido2 import webauthn
from fido2.server import Fido2Server
import plinth.modules.ssh.privileged as ssh_privileged
from plinth import translation
from plinth.models import UserPasskey
from plinth.modules import first_boot
from plinth.utils import is_user_admin
from plinth.views import AppView
from plinth.version import Version
from plinth.views import AppView, json_exception
from . import privileged
from .forms import (AuthenticationForm, CaptchaForm, CreateUserForm,
FirstBootForm, UserChangePasswordForm, UserUpdateForm)
# Enable fido2 basic features
if Version(importlib.metadata.version('fido2')) < Version('2.0.0'):
fido2.features.webauthn_json_mapping.enabled = True # type: ignore
logger = logging.getLogger(__name__)
class LoginView(DjangoLoginView):
"""View to login to FreedomBox and set language preference."""
@ -257,3 +279,170 @@ class FirstBootView(django.views.generic.CreateView):
def get_success_url(self):
"""Return the next first boot step after valid form submission."""
return reverse(first_boot.next_step())
def get_fido2_server(domain: str) -> Fido2Server:
"""Return an instance of a Fido2Server."""
relying_party = webauthn.PublicKeyCredentialRpEntity(
id=domain, name='FreedomBox')
return Fido2Server(relying_party)
def require_owner_or_admin(view_func):
"""Decorator to check if the view is called by owner or admin."""
@functools.wraps(view_func)
def _wrapped_view(request, *args, **kwargs):
"""Wrap a view method and check ownership is valid."""
if (kwargs['username'] != request.user.get_username()
and not is_user_admin(request)):
raise PermissionDenied
return view_func(request, *args, **kwargs)
return _wrapped_view
@method_decorator(require_owner_or_admin, name='dispatch')
class PasskeysList(ContextMixin, django.views.generic.ListView):
"""View to show a list of current passkeys for the user."""
model = UserPasskey
template_name = 'users_passkeys.html'
title = gettext_lazy('Passkeys')
def get_queryset(self):
"""Show only list of passkeys for this user."""
return self.model.objects.filter(
user__username=self.kwargs['username']).order_by('created_time')
@json_exception
@require_owner_or_admin
@require_POST
def passkey_add_begin(request, username):
"""Verify passkey registration and add passkey to user's account."""
user = User.objects.get(username=username)
# Domain
domain = request.get_host().partition(':')[0]
# User
username = user.get_username()
user_entity = webauthn.PublicKeyCredentialUserEntity(
id=user.id, name=username, display_name=username)
# Begin registration
server = get_fido2_server(domain)
create_options, state = server.register_begin(
user_entity,
user_verification=webauthn.UserVerificationRequirement.REQUIRED,
resident_key_requirement=webauthn.ResidentKeyRequirement.PREFERRED)
logger.info('Passkey creation began for user - %s (%s)', username, user.id)
request.session['fido2_server_state'] = state
return JsonResponse(dict(create_options))
def _passkey_get_next_name(user):
"""Return a suitable name for the next passkey of the given user."""
number = 1
for passkey in user.passkeys.all():
match = re.match(r'^Key (\d+)$', passkey.name)
if match:
number = max(number, int(match[1]) + 1)
return f'Key {number}'
@json_exception
@require_owner_or_admin
@require_POST
def passkey_add_complete(request, username):
"""Verify passkey registration and add passkey to user's account."""
def _response(result: bool, error_string: str):
"""Return a JsonResponse object."""
status = 200
if not result:
status = 400
logger.error('Error completing passkey registration: %s',
error_string)
return JsonResponse({
'result': result,
'error_string': error_string
}, status=status)
user = User.objects.get(username=username)
try:
response = json.loads(request.body)
except json.decoder.JSONDecodeError as exception:
return _response(False, str(exception))
# Domain
domain = request.get_host().partition(':')[0]
# State
state = request.session.get('fido2_server_state')
# Complete registration
server = get_fido2_server(domain)
try:
authenticator_data = server.register_complete(state, response)
logger.info('Passkey creation completed for user - %s (%s)',
user.get_username(), user.id)
assert authenticator_data.is_user_present()
assert authenticator_data.is_user_verified()
logger.info('Passkey user is present and verified.')
except Exception as exception:
return _response(False, str(exception))
try:
credential_data = authenticator_data.credential_data
UserPasskey.objects.create(
user=user,
name=_passkey_get_next_name(user),
domain=domain,
signature_counter=authenticator_data.counter,
registration_flags=authenticator_data.flags,
extensions=authenticator_data.extensions,
aaguid=uuid.UUID(f'urn:uuid:{credential_data.aaguid}'),
credential_id=credential_data.credential_id,
public_key=fido2.cbor.encode(credential_data.public_key),
)
except IntegrityError:
return _response(False,
_('Passkey with that identifier already exists.'))
return _response(True, None)
@method_decorator(require_owner_or_admin, name='dispatch')
class PasskeyEdit(ContextMixin, UpdateView):
"""View to allow editing a passkey's name."""
model = UserPasskey
fields = ['name']
pk_url_kwarg = 'passkey_id'
title = _('Edit Passkey')
def get_template_names(self):
"""Return the template name to use."""
return ['users_passkey_edit.html']
def get_success_url(self):
"""Return the URL to visit if form edit succeeds."""
return reverse('users:passkeys', args=[self.kwargs['username']])
@method_decorator(require_owner_or_admin, name='dispatch')
@method_decorator(require_POST, name='dispatch')
class PasskeyDelete(DeleteView):
"""View to delete a passkey."""
model = UserPasskey
pk_url_kwarg = 'passkey_id'
def get_success_url(self):
"""Return the URL to visit if form edit succeeds."""
return reverse('users:passkeys', args=[self.kwargs['username']])

View File

@ -173,6 +173,13 @@
{% trans "Edit" %}
</a>
</li>
<li>
<a id="id_manage_passkeys_menu" class="dropdown-item"
href="{% url 'users:passkeys' request.user.username %}"
title="{% trans "Manage passkeys" %}">
{% trans "Manage passkeys" %}
</a>
</li>
<li>
<a id="id_change_password_menu" class="dropdown-item"
href="{% url 'users:change_password' request.user.username %}"