1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-09 03:36:48 +00:00

feat(accounts): the profile with details beside the user's keys and tokens

The Profile page was three tabs, each a box inside a box with horizontal
labels: the SSH keys a centred bare row with a grey trash icon, a tall
empty textarea under them, and the user known only by the heading.

Now the user's name, username, role, email and last login head a card
that holds the profile form and the password link; beside it the SSH
keys and the API tokens are cards with their count, a line on what
they are for, one row each with Delete in a menu, and the form to add
one behind a button. Adding or deleting a key returns to #sshkeys.

The token rows are a list-group include that the administrator's view
of a user shares.
This commit is contained in:
catborise 2026-10-31 13:12:57 +03:00
parent c0978419f2
commit 3c7346c820
6 changed files with 116 additions and 130 deletions

View file

@ -109,7 +109,7 @@
</div>
<div class="tab-pane fade" id="apitokens">
{% if api_tokens %}
{% include "api_token_table.html" with owner=user %}
<div class="list-group mt-3">{% include "api_token_list.html" with owner=user %}</div>
{% else %}
<p class="text-body-secondary mt-3">{% trans "No API tokens." %}</p>
{% endif %}

View file

@ -0,0 +1,23 @@
{% load i18n %}
{% load bootstrap_icons %}
{# the tokens of the signed-in user, or of owner on a superuser's view of a user: the items of a list-group #}
{% for token in api_tokens %}
<div class="list-group-item py-3">
<div class="d-flex justify-content-between align-items-center gap-2">
<span class="fw-semibold text-break">{{ token.name }}</span>
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false"
aria-label="{% blocktrans with name=token.name %}Actions for API token {{ name }}{% endblocktrans %}">{% bs_icon 'three-dots' %}</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item text-danger" href="{% if owner %}{% url 'accounts:account_api_token_delete' owner.id token.token_key %}{% else %}{% url 'accounts:api_token_delete' token.token_key %}{% endif %}">{% trans "Delete" %}</a></li>
</ul>
</div>
</div>
<div class="small mt-1">{% trans "Starts with" %} <code>{{ token.token_key }}</code></div>
<div class="row g-2 small mt-1">
<div class="col-12 col-md-4"><span class="text-body-secondary">{% trans "Created" %}</span> <span class="text-nowrap">{{ token.created|date:"Y-m-d H:i" }}</span></div>
<div class="col-12 col-md-4"><span class="text-body-secondary">{% trans "Expires" %}</span> <span class="text-nowrap">{% if token.expiry %}{{ token.expiry|date:"Y-m-d H:i" }}{% else %}{% trans "never" %}{% endif %}</span></div>
<div class="col-12 col-md-4"><span class="text-body-secondary">{% trans "Last used" %}</span> <span class="text-nowrap">{% if token.last_used %}{{ token.last_used|date:"Y-m-d H:i" }}{% else %}{% trans "never" %}{% endif %}</span></div>
</div>
</div>
{% endfor %}

View file

@ -1,33 +0,0 @@
{% load i18n %}
{% load bootstrap_icons %}
{# the tokens of the signed-in user, or of owner on a superuser's view of a user #}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th scope="col">{% trans "Name" %}</th>
<th scope="col">{% trans "Starts with" %}</th>
<th scope="col">{% trans "Created" %}</th>
<th scope="col">{% trans "Expires" %}</th>
<th scope="col">{% trans "Last used" %}</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{% for token in api_tokens %}
<tr>
<td>{{ token.name }}</td>
<td><code>{{ token.token_key }}</code></td>
<td>{{ token.created|date:"Y-m-d H:i" }}</td>
<td>{% if token.expiry %}{{ token.expiry|date:"Y-m-d H:i" }}{% else %}{% trans "never" %}{% endif %}</td>
<td>{% if token.last_used %}{{ token.last_used|date:"Y-m-d H:i" }}{% else %}{% trans "never" %}{% endif %}</td>
<td class="text-end">
<a href="{% if owner %}{% url 'accounts:account_api_token_delete' owner.id token.token_key %}{% else %}{% url 'accounts:api_token_delete' token.token_key %}{% endif %}" title="{% trans "Delete" %}" class="btn btn-sm btn-secondary">
{% bs_icon 'trash' %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

View file

@ -4,112 +4,93 @@
{% load bootstrap_icons %}
{% load tags_fingerprint %}
{% block title %}{% trans "Profile" %}: {{ request.user.first_name }} {{ request.user.last_name}}{% endblock %}
{% block title %}{% trans "Profile" %}{% endblock %}
{% block page_heading %}{% trans "Profile" %}: {{ request.user.first_name }} {{ request.user.last_name}}{% endblock page_heading %}
{% block page_heading %}{% trans "Profile" %}{% endblock page_heading %}
{% block content %}
<ul class="nav nav-tabs">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#editprofile" type="button" role="tab" aria-controls="editprofile" aria-selected="true">
{% trans "Edit Profile" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#sshkeys" type="button" role="tab" aria-controls="sshkeys" aria-selected="false">
{% trans "SSH Keys" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#apitokens" type="button" role="tab" aria-controls="apitokens" aria-selected="false">
{% trans "API Tokens" %}
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane border border-top-0 p-3 active" id="editprofile">
<div class="card">
<div class="card-body">
<form method="post" action="" role="form" aria-label="Edit user info form">
{% csrf_token %}
{% bootstrap_form profile_form layout='horizontal' %}
{# an LDAP user's password is changed in LDAP #}
{% if perms.accounts.change_password and user.has_usable_password %}
<a href="{% url 'accounts:change_password' %}" class="btn btn-primary">
{% bs_icon 'lock' %} {% trans "Change Password" %}
</a>
<div class="row g-4">
<div class="col-lg-4">
<section class="card" aria-labelledby="identity-title">
<div class="card-body border-bottom">
<div class="d-flex flex-wrap align-items-center gap-2 mb-1">
<h2 class="h4 mb-0 text-break" id="identity-title">{{ user.get_full_name|default:user.username }}</h2>
{% if user.is_superuser %}
<span class="badge text-bg-secondary">{% trans "Superuser" %}</span>
{% elif user.is_staff %}
<span class="badge text-bg-secondary">{% trans "Template editor" %}</span>
{% endif %}
<div class="mb-0 float-end">
<button type="submit" class="btn btn-primary">
{% bs_icon 'pencil' %} {% trans "Update" %}
</button>
</div>
</form>
</div>
<div class="text-body-secondary text-break">{{ user.username }}</div>
{% if user.email %}<div class="small text-break mt-2">{{ user.email }}</div>{% endif %}
<div class="small text-body-secondary mt-2">{% trans "Last login" %} {{ user.last_login|date:"Y-m-d H:i"|default:_("never") }}</div>
</div>
</div>
<form method="post" class="card-body" aria-label="{% trans 'Edit user info form' %}">
{% csrf_token %}
{% bootstrap_form profile_form %}
<button type="submit" class="btn btn-primary w-100">{% trans "Save profile" %}</button>
</form>
{# an LDAP user's password is changed in LDAP #}
{% if perms.accounts.change_password and user.has_usable_password %}
<div class="card-footer">
<a href="{% url 'accounts:change_password' %}" class="btn btn-sm btn-outline-secondary">{% bs_icon 'lock' %} {% trans "Change Password" %}</a>
</div>
{% endif %}
</section>
</div>
<div class="tab-pane border border-top-0 p-3 fade" id="sshkeys">
{% if publickeys %}
<div class="col-lg-12">
<div class="table-responsive">
<table class="table table-hover align-middle">
<tbody class="text-center">
{% for key in publickeys %}
<tr>
<td>{{ key.keyname }} ({% ssh_to_fingerprint key.keypublic %})</td>
<td>
<a href="{% url 'accounts:ssh_key_delete' key.id %}" title="{% trans "Delete" %}" class="btn btn-sm btn-secondary">
{% bs_icon 'trash' %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="col-lg-8">
<section class="card mb-4" id="sshkeys" aria-labelledby="sshkeys-title">
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
<h2 class="h5 mb-0" id="sshkeys-title">{% trans "SSH Keys" %} <span class="badge text-bg-light border">{{ publickeys|length }}</span></h2>
<button class="btn btn-sm btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#add-ssh-key" aria-expanded="false" aria-controls="add-ssh-key">
{% bs_icon 'plus-lg' %} {% trans "Add SSH Key" %}
</button>
</div>
</div>
{% endif %}
<div class="card">
<div class="card-header">
{%trans "Add SSH Key" %}
</div>
<div class="card-body">
<form method="post" action="{% url 'accounts:ssh_key_create' %}" role="form" aria-label="Add key to user form">
{% csrf_token %}
{% bootstrap_form ssh_key_form layout='horizontal' %}
<div class="mb-0 float-end">
<button type="submit" class="btn btn-primary">
{% bs_icon 'plus-circle-fill' %} {% trans "Add" %}
</button>
<div class="card-body small text-body-secondary">{% trans "Offered when you create a VM and on a VM's Access tab." %}</div>
<div class="list-group list-group-flush">
{% for key in publickeys %}
<div class="list-group-item py-3">
<div class="d-flex justify-content-between align-items-center gap-2">
<span class="fw-semibold text-break">{{ key.keyname }}</span>
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false"
aria-label="{% blocktrans with name=key.keyname %}Actions for SSH key {{ name }}{% endblocktrans %}">{% bs_icon 'three-dots' %}</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item text-danger" href="{% url 'accounts:ssh_key_delete' key.id %}">{% trans "Delete" %}</a></li>
</ul>
</div>
</div>
<div class="font-monospace small text-break mt-1">{% ssh_to_fingerprint key.keypublic %}</div>
</div>
{% endfor %}
</div>
<div class="collapse" id="add-ssh-key">
<form method="post" action="{% url 'accounts:ssh_key_create' %}" class="card-body border-top" aria-label="{% trans 'Add key to user form' %}">
{% csrf_token %}
{% bootstrap_form ssh_key_form %}
<button type="submit" class="btn btn-primary">{% trans "Add" %}</button>
</form>
</div>
</div>
</div>
<div class="tab-pane border border-top-0 p-3 fade" id="apitokens">
<p class="text-body-secondary">{% blocktrans %}A token lets a script or a tool use the REST API as you, without your password or OTP: send it as <code>Authorization: Token &lt;token&gt;</code>. A password change does not delete tokens: delete a token you no longer use, or one that may have leaked.{% endblocktrans %}</p>
{% if api_tokens %}
{% include "api_token_table.html" %}
{% endif %}
<div class="card">
<div class="card-header">
{% trans "Create an API token" %}
</section>
<section class="card" id="apitokens" aria-labelledby="apitokens-title">
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
<h2 class="h5 mb-0" id="apitokens-title">{% trans "API Tokens" %} <span class="badge text-bg-light border">{{ api_tokens|length }}</span></h2>
<button class="btn btn-sm btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#create-api-token" aria-expanded="false" aria-controls="create-api-token">
{% bs_icon 'plus-lg' %} {% trans "Create an API token" %}
</button>
</div>
<div class="card-body">
<form method="post" action="{% url 'accounts:api_token_create' %}" role="form" aria-label="{% trans "Create an API token" %}">
<div class="card-body small text-body-secondary">{% blocktrans %}A token lets a script or a tool use the REST API as you, without your password or OTP: send it as <code>Authorization: Token &lt;token&gt;</code>. A password change does not delete tokens: delete a token you no longer use, or one that may have leaked.{% endblocktrans %}</div>
<div class="list-group list-group-flush">{% include "api_token_list.html" %}</div>
<div class="collapse" id="create-api-token">
<form method="post" action="{% url 'accounts:api_token_create' %}" class="card-body border-top" aria-label="{% trans 'Create an API token' %}">
{% csrf_token %}
{% bootstrap_form api_token_form layout='horizontal' %}
<div class="mb-0 float-end">
<button type="submit" class="btn btn-primary">
{% bs_icon 'plus-circle-fill' %} {% trans "Create" %}
</button>
</div>
{% bootstrap_form api_token_form %}
<button type="submit" class="btn btn-primary">{% trans "Create" %}</button>
</form>
</div>
</div>
</section>
</div>
</div>
{% endblock %}
{% block script %}
{% include "tab_from_hash.html" %}
{% endblock %}

View file

@ -236,7 +236,7 @@ class AccountsTestCase(TestCase):
"keypublic": self.rsa_key,
},
)
self.assertRedirects(response, reverse("accounts:profile"))
self.assertRedirects(response, reverse("accounts:profile") + "#sshkeys")
key = UserSSHKey.objects.get(keyname="keyname")
self.assertEqual(key.keyname, "keyname")
@ -246,7 +246,22 @@ class AccountsTestCase(TestCase):
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("accounts:ssh_key_delete", args=[key.id]))
self.assertRedirects(response, reverse("accounts:profile"))
self.assertRedirects(response, reverse("accounts:profile") + "#sshkeys")
def test_the_profile_lists_keys_and_tokens_where_links_land(self):
admin = get_user_model().objects.get(username="admin")
key = UserSSHKey.objects.create(user=admin, keyname="laptop", keypublic=self.rsa_key)
response = self.client.get(reverse("accounts:profile"))
# other pages link to #sshkeys and #apitokens
self.assertContains(response, 'id="sshkeys"')
self.assertContains(response, 'id="apitokens"')
self.assertContains(response, f'href="{reverse("accounts:ssh_key_delete", args=[key.id])}"')
self.assertContains(response, f'href="{reverse("accounts:change_password")}"')
# an LDAP user's password is changed in LDAP
admin.set_unusable_password()
admin.save()
self.client.force_login(admin)
self.assertNotContains(self.client.get(reverse("accounts:profile")), f'href="{reverse("accounts:change_password")}"')
def test_validate_ssh_key(self):
self.assertFalse(validate_ssh_key(""))

View file

@ -124,7 +124,7 @@ def ssh_key_create(request):
with audit(request, None, f"SSH key {key_form.cleaned_data['keyname']}", "add"):
key_form.save()
messages.success(request, _("SSH key added"))
return redirect("accounts:profile")
return redirect(reverse("accounts:profile") + "#sshkeys")
return render(
request,
@ -142,7 +142,7 @@ def ssh_key_delete(request, pk, user_id=None):
if user_id is None:
ssh_key = get_object_or_404(UserSSHKey, pk=pk, user=request.user)
subject = f"SSH key {ssh_key.keyname}"
back = reverse("accounts:profile")
back = reverse("accounts:profile") + "#sshkeys"
elif request.user.is_superuser:
ssh_key = get_object_or_404(UserSSHKey, pk=pk, user_id=user_id)
subject = f"SSH key {ssh_key.keyname} of {ssh_key.user.username}"