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:
parent
c0978419f2
commit
3c7346c820
6 changed files with 116 additions and 130 deletions
|
|
@ -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 %}
|
||||
|
|
|
|||
23
accounts/templates/api_token_list.html
Normal file
23
accounts/templates/api_token_list.html
Normal 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 %}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 <token></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 <token></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 %}
|
||||
|
|
|
|||
|
|
@ -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(""))
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue