mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
feat(accounts): the administrator's view of a user as an identity strip and cards
A superuser's page of a user was a heading, two unlabelled icon buttons and three tabs: the VMs a table of check columns, the SSH keys a table that pushed the page sideways on a phone. Now a card at the top shows the user's name, role, Blocked when they are, username, email, groups, last login and joining date, with Edit user and Email OTP QR code as labelled buttons and links to the sections below with their counts. The VMs card shows each VM with its host and what the user may do beyond power and console as badges in the VM Users tab's words (Change settings, Delete the VM, Change console settings), with Change access and Remove from user in a menu, and Add VM in its header. The SSH keys and the API tokens are cards; the key rows are now one include with the user's own Profile, and both lists say when they are empty. The full key shows over its fingerprint. The tab script had no page left and goes.
This commit is contained in:
parent
3c7346c820
commit
0044359202
7 changed files with 131 additions and 136 deletions
|
|
@ -4,118 +4,94 @@
|
|||
{% load bootstrap_icons %}
|
||||
|
||||
{% block title %}{% trans "User Profile" %} - {{ user }}{% endblock %}
|
||||
{% block page_heading %}{% trans "User Profile" %}: {{ user }}{% endblock page_heading %}
|
||||
|
||||
{% block page_heading_extra %}
|
||||
{% if otp_enabled %}
|
||||
<form method="post" action="{% url 'accounts:admin_email_otp' user.id %}" class="d-inline">{% csrf_token %}
|
||||
<button type="submit" class="btn btn-secondary" title="{% trans "Email OTP QR code" %}">
|
||||
{% bs_icon 'qr-code' %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="{% url 'admin:user_update' user.id %}?next={% url 'accounts:account' user.id %}" class="btn btn-primary" title="{% trans "Edit user" %}">
|
||||
{% bs_icon 'pencil' %}
|
||||
</a>
|
||||
<a href="{% url 'accounts:user_instance_create' user.id %}" class="btn btn-success" title="{% trans "Create user instance" %}">
|
||||
{% bs_icon 'plus-circle-fill' %}
|
||||
</a>
|
||||
{% endblock page_heading_extra %}
|
||||
{% block page_heading %}{% trans "User 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="#instances" type="button" role="tab" aria-controls="instances" aria-selected="true">
|
||||
{% trans "Instances" %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#public-keys" type="button" role="tab" aria-controls="public-keys" aria-selected="false">
|
||||
{% trans "Public 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 active" id="instances">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">{% trans "Instance" %}</th>
|
||||
<th scope="col">{% trans "VNC" %}</th>
|
||||
<th scope="col">{% trans "Resize" %}</th>
|
||||
<th scope="col">{% trans "Delete" %}</th>
|
||||
<th scope="colgroup" colspan="2">{% trans "Action" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for inst in user_insts %}
|
||||
<tr>
|
||||
<td>{{ forloop.counter }}</td>
|
||||
<td><a href="{% url 'instances:instance' inst.instance.id %}">{{ inst.instance.name }}</a></td>
|
||||
<td>{% if inst.is_vnc %}{% bs_icon 'check' %}{% endif %}</td>
|
||||
<td>{% if inst.is_change %}{% bs_icon 'check' %}{% endif %}</td>
|
||||
<td>{% if inst.is_delete %}{% bs_icon 'check' %}{% endif %}</td>
|
||||
<td style="width:5px;">
|
||||
<a href="{% url 'accounts:user_instance_update' inst.id %}" class="btn btn-sm btn-secondary" title="{% trans "edit" %}">
|
||||
{% bs_icon 'pencil' %}
|
||||
</a>
|
||||
</td>
|
||||
<td style="width:5px;">
|
||||
<a class="btn btn-sm btn-secondary" href="{% url 'accounts:user_instance_delete' inst.id %}" title="{% trans "Delete" %}">
|
||||
{% bs_icon 'trash' %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="7" class="text-body-secondary">{% trans "No VMs." %}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<section class="card mb-4" aria-labelledby="identity-title">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-start">
|
||||
<div class="col-lg-8">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<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 %}
|
||||
{% if not user.is_active %}<span class="badge text-bg-danger">{% trans "Blocked" %}</span>{% endif %}
|
||||
</div>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 small mt-2">
|
||||
<span class="text-body-secondary text-break">{{ user.username }}</span>
|
||||
{% if user.email %}<span class="text-break">{{ user.email }}</span>{% endif %}
|
||||
{% for group in user.groups.all %}<span class="badge text-bg-light border mw-100 text-wrap text-break">{{ group.name }}</span>{% endfor %}
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 gap-md-4 small text-body-secondary mt-3">
|
||||
<span>{% trans "Last login" %} {{ user.last_login|date:"Y-m-d H:i"|default:_("never") }}</span>
|
||||
<span>{% trans "Joined" %} {{ user.date_joined|date:"Y-m-d" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="public-keys">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{% trans "Key name" %}</th>
|
||||
<th scope="col">{% trans "Public key" %}</th>
|
||||
<th scope="col"><span class="visually-hidden">{% trans "Action" %}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for publickey in publickeys %}
|
||||
<tr>
|
||||
<td>{{ publickey.keyname }}</td>
|
||||
<td title="{{ publickey.keypublic }}">{{ publickey.keypublic|truncatechars:64 }}</td>
|
||||
<td style="width:5px;">
|
||||
<a class="btn btn-sm btn-secondary" href="{% url 'accounts:account_ssh_key_delete' user.id publickey.id %}" title="{% trans "Delete" %}">
|
||||
{% bs_icon 'trash' %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="col-lg-4 d-flex flex-wrap justify-content-lg-end gap-2">
|
||||
<a href="{% url 'admin:user_update' user.id %}?next={% url 'accounts:account' user.id %}" class="btn btn-primary">{% bs_icon 'pencil' %} {% trans "Edit user" %}</a>
|
||||
{% if otp_enabled %}
|
||||
<form method="post" action="{% url 'accounts:admin_email_otp' user.id %}">{% csrf_token %}
|
||||
<button type="submit" class="btn btn-outline-secondary">{% bs_icon 'qr-code' %} {% trans "Email OTP QR code" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="apitokens">
|
||||
{% if api_tokens %}
|
||||
<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 %}
|
||||
</div>
|
||||
</div>
|
||||
<nav class="card-footer d-flex flex-wrap gap-3 small" aria-label="{% trans 'User sections' %}">
|
||||
<a href="#instances">{% trans "VMs" %} <span class="badge text-bg-light border">{{ user_insts|length }}</span></a>
|
||||
<a href="#public-keys">{% trans "SSH Keys" %} <span class="badge text-bg-light border">{{ publickeys|length }}</span></a>
|
||||
<a href="#apitokens">{% trans "API Tokens" %} <span class="badge text-bg-light border">{{ api_tokens|length }}</span></a>
|
||||
</nav>
|
||||
</section>
|
||||
|
||||
<section class="card mb-4" id="instances" aria-labelledby="instances-title">
|
||||
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||
<h2 class="h5 mb-0" id="instances-title">{% trans "VMs" %} <span class="badge text-bg-light border">{{ user_insts|length }}</span></h2>
|
||||
<a href="{% url 'accounts:user_instance_create' user.id %}" class="btn btn-sm btn-primary">{% bs_icon 'plus-lg' %} {% trans "Add VM" %}</a>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for inst in user_insts %}
|
||||
<div class="list-group-item py-3">
|
||||
<div class="d-flex align-items-start align-items-md-center gap-3">
|
||||
<div class="row g-2 align-items-center flex-grow-1" style="min-width: 0;">
|
||||
<div class="col-12 col-md-6">
|
||||
<a class="fw-semibold text-break" href="{% url 'instances:instance' inst.instance.id %}">{{ inst.instance.name }}</a>
|
||||
<div class="small text-body-secondary text-break">{% trans "Host" %}: {{ inst.instance.compute.name }}</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 d-flex flex-wrap gap-1">
|
||||
{# the rights as the VM's Users tab names them: every owner powers the VM and opens its console #}
|
||||
{% if inst.is_change %}<span class="badge text-bg-light border">{% trans "Change settings" %}</span>{% endif %}
|
||||
{% if inst.is_delete %}<span class="badge text-bg-light border">{% trans "Delete the VM" %}</span>{% endif %}
|
||||
{% if inst.is_change and inst.is_vnc %}<span class="badge text-bg-light border">{% trans "Change console settings" %}</span>{% endif %}
|
||||
{% if not inst.is_change and not inst.is_delete %}<span class="small text-body-secondary">{% trans "Power and console only" %}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown flex-shrink-0">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false"
|
||||
aria-label="{% blocktrans with name=inst.instance.name %}Actions for {{ name }}{% endblocktrans %}">{% bs_icon 'three-dots' %}</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="{% url 'accounts:user_instance_update' inst.id %}">{% trans "Change access" %}</a></li>
|
||||
<li><a class="dropdown-item text-danger" href="{% url 'accounts:user_instance_delete' inst.id %}">{% trans "Remove from user" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-body-secondary">{% trans "No VMs." %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card mb-4" id="public-keys" aria-labelledby="public-keys-title">
|
||||
<div class="card-header"><h2 class="h5 mb-0" id="public-keys-title">{% trans "SSH Keys" %} <span class="badge text-bg-light border">{{ publickeys|length }}</span></h2></div>
|
||||
<div class="list-group list-group-flush">{% include "ssh_key_list.html" with owner=user %}</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="apitokens" aria-labelledby="apitokens-title">
|
||||
<div class="card-header"><h2 class="h5 mb-0" id="apitokens-title">{% trans "API Tokens" %} <span class="badge text-bg-light border">{{ api_tokens|length }}</span></h2></div>
|
||||
<div class="list-group list-group-flush">{% include "api_token_list.html" with owner=user %}</div>
|
||||
</section>
|
||||
{% endblock content %}
|
||||
{% block script %}
|
||||
{% include "tab_from_hash.html" %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -20,4 +20,6 @@
|
|||
<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>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-body-secondary">{% trans "No API tokens." %}</div>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
{% load i18n %}
|
||||
{% load django_bootstrap5 %}
|
||||
{% load bootstrap_icons %}
|
||||
{% load tags_fingerprint %}
|
||||
|
||||
{% block title %}{% trans "Profile" %}{% endblock %}
|
||||
|
||||
|
|
@ -48,23 +47,7 @@
|
|||
</button>
|
||||
</div>
|
||||
<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="list-group list-group-flush">{% include "ssh_key_list.html" %}</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 %}
|
||||
|
|
|
|||
21
accounts/templates/ssh_key_list.html
Normal file
21
accounts/templates/ssh_key_list.html
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{% load i18n %}
|
||||
{% load bootstrap_icons %}
|
||||
{% load tags_fingerprint %}
|
||||
{# the keys of the signed-in user, or of owner on a superuser's view of a user: the items of a list-group #}
|
||||
{% 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="{% if owner %}{% url 'accounts:account_ssh_key_delete' owner.id key.id %}{% else %}{% url 'accounts:ssh_key_delete' key.id %}{% endif %}">{% trans "Delete" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-monospace small text-break mt-1" title="{{ key.keypublic }}">{% ssh_to_fingerprint key.keypublic %}</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-body-secondary">{% trans "No SSH keys." %}</div>
|
||||
{% endfor %}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<script>
|
||||
// a link to a tab (#apitokens) opens it
|
||||
var linked = location.hash.length > 1 && document.querySelector('[data-bs-target="#' + CSS.escape(location.hash.slice(1)) + '"]');
|
||||
if (linked) {
|
||||
bootstrap.Tab.getOrCreateInstance(linked).show();
|
||||
}
|
||||
</script>
|
||||
|
|
@ -30,9 +30,29 @@ class AccountPageTests(TestCase):
|
|||
self.assertContains(response, "ap-vm")
|
||||
self.assertNotContains(response, "<td>True</td>", html=True)
|
||||
self.assertNotContains(response, "<td>False</td>", html=True)
|
||||
# what the user may do beyond power and console, in the VM Users tab's words, and the VM's host
|
||||
self.assertContains(response, "Power and console only")
|
||||
self.assertNotContains(response, "Change settings")
|
||||
self.assertContains(response, "Host: ap-host")
|
||||
# the console settings come with the right to change the VM
|
||||
UserInstance.objects.filter(user=self.alice).update(is_change=True)
|
||||
response = self.page()
|
||||
self.assertContains(response, "Change settings")
|
||||
self.assertContains(response, "Change console settings")
|
||||
self.assertNotContains(response, "Power and console only")
|
||||
|
||||
def test_a_user_without_vms_is_told_so(self):
|
||||
self.assertContains(self.page(), "No VMs.")
|
||||
def test_a_blocked_user_is_marked(self):
|
||||
self.assertNotContains(self.page(), "Blocked")
|
||||
self.alice.is_active = False
|
||||
self.alice.save()
|
||||
self.assertContains(self.page(), '<span class="badge text-bg-danger">Blocked</span>', html=True)
|
||||
|
||||
def test_a_user_without_vms_or_tokens_is_told_so(self):
|
||||
response = self.page()
|
||||
self.assertContains(response, "No VMs.")
|
||||
self.assertContains(response, "No API tokens.")
|
||||
# the whole key shows over its fingerprint
|
||||
self.assertContains(response, f'title="{KEY}"')
|
||||
|
||||
def test_a_superuser_deletes_a_users_key(self):
|
||||
url = reverse("accounts:account_ssh_key_delete", args=[self.alice.pk, self.key.pk])
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ def ssh_key_delete(request, pk, user_id=None):
|
|||
@superuser_only
|
||||
def account(request, user_id):
|
||||
user = get_object_or_404(User, id=user_id)
|
||||
user_insts = UserInstance.objects.filter(user_id=user_id).select_related("instance")
|
||||
user_insts = UserInstance.objects.filter(user_id=user_id).select_related("instance__compute")
|
||||
publickeys = UserSSHKey.objects.filter(user_id=user_id)
|
||||
|
||||
return render(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue