Skip to content

Commit

Permalink
Merge pull request #166 from snikket-im/feature/admin-users-ui-update…
Browse files Browse the repository at this point in the history
…s-dec-23

Admin user management UI updates (Dec 23)
  • Loading branch information
mwild1 authored Dec 8, 2023
2 parents bd66600 + 55b195c commit 74ecfb8
Show file tree
Hide file tree
Showing 11 changed files with 457 additions and 94 deletions.
36 changes: 35 additions & 1 deletion snikket_web/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,14 @@ class EditUserForm(BaseForm):
_l("Update user"),
)

action_restore = wtforms.SubmitField(
_l("Restore account"),
)

action_enable = wtforms.SubmitField(
_l("Unlock account"),
)

action_create_reset = wtforms.SubmitField(
_l("Create password reset link"),
)
Expand All @@ -112,6 +120,32 @@ async def edit_user(localpart: str) -> typing.Union[werkzeug.Response, str]:
".user_password_reset_link",
id_=reset_link.id_,
))
elif form.action_restore.data or form.action_enable.data:
await client.enable_user_account(localpart)
try:
if form.action_restore.data:
await flash(
_("User account restored"),
"success",
)
else:
await flash(
_("User account unlocked"),
"success",
)
return redirect(url_for(".users"))
except aiohttp.ClientResponseError:
if form.action_restore.data:
await flash(
_("Could not restore user account"),
"alert",
)
else:
await flash(
_("Could not unlock user account"),
"alert",
)
return redirect(url_for(".edit_user", localpart=localpart))

await client.update_user(
localpart,
Expand All @@ -123,7 +157,7 @@ async def edit_user(localpart: str) -> typing.Union[werkzeug.Response, str]:
_("User information updated."),
"success",
)
return redirect(url_for(".edit_user", localpart=localpart))
return redirect(url_for(".users"))

elif request.method == "GET":
form.localpart.data = target_user_info.localpart
Expand Down
44 changes: 43 additions & 1 deletion snikket_web/infra.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import secrets
import typing

from datetime import datetime, timedelta, timezone

import quart.flask_patch # noqa:F401
from quart import (
current_app,
Expand All @@ -13,7 +15,8 @@

import flask_babel
import flask_wtf
from flask_babel import _
from flask_babel import lazy_gettext as _l
import flask_babel as _

from . import prosodyclient

Expand Down Expand Up @@ -70,6 +73,43 @@ def format_bytes(n: float) -> str:
return "{} {}".format(n, unit)


def format_last_activity(timestamp: typing.Optional[int]) -> str:
if timestamp is None:
return _l("Never")

last_active = datetime.fromtimestamp(timestamp, tz=timezone.utc)
# TODO: This 'now' should use the user's local time zone, but we
# don't have that information. Thus 'today'/'yesterday' may be
# slightly inaccurate, but compared to alternative solutions it
# should hopefully be "good enough".
now = datetime.now(tz=timezone.utc)
time_ago = now - last_active

yesterday = now - timedelta(days=1)

if (
last_active.year == now.year
and last_active.month == now.month
and last_active.day == now.day
):
return _l("Today")
elif (
last_active.year == yesterday.year
and last_active.month == yesterday.month
and last_active.day == yesterday.day
):
return _l("Yesterday")

return _.gettext(
"%(time)s ago",
time=flask_babel.format_timedelta(time_ago, granularity="day"),
)


def template_now() -> typing.Dict[str, typing.Any]:
return dict(now=lambda: datetime.now(timezone.utc))


def add_vary_language_header(resp: quart.Response) -> quart.Response:
if getattr(g, "language_header_accessed", False):
resp.vary.add("Accept-Language")
Expand All @@ -86,6 +126,8 @@ def init_templating(app: quart.Quart) -> None:
app.template_filter("format_bytes")(format_bytes)
app.template_filter("flatten")(flatten)
app.template_filter("circle_name")(circle_name)
app.template_filter("format_last_activity")(format_last_activity)
app.context_processor(template_now)
app.after_request(add_vary_language_header)


Expand Down
91 changes: 90 additions & 1 deletion snikket_web/prosodyclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import typing
import typing_extensions

from datetime import datetime
from datetime import datetime, timezone

import aiohttp

Expand Down Expand Up @@ -42,13 +42,63 @@ class TokenInfo:
scopes: typing.Collection[str]


@dataclasses.dataclass(frozen=True)
class UserDeletionRequestInfo:
deleted_at: datetime
pending_until: datetime

@classmethod
def from_api_response(
cls,
data: typing.Optional[typing.Mapping[str, typing.Any]],
) -> typing.Optional["UserDeletionRequestInfo"]:
if data is None:
return None
return cls(
deleted_at=datetime.fromtimestamp(
data["deleted_at"],
tz=timezone.utc
),
pending_until=datetime.fromtimestamp(
data["pending_until"],
tz=timezone.utc
)
)


@dataclasses.dataclass(frozen=True)
class AvatarMetadata:
bytes: int
hash: str
type: str
width: typing.Optional[int]
height: typing.Optional[int]

@classmethod
def from_api_response(
cls,
data: typing.Mapping[str, typing.Any],
) -> "AvatarMetadata":
return cls(
hash=data["hash"],
bytes=data["bytes"],
type=data["type"],
width=data.get("width") or None,
height=data.get("height") or None,
)


@dataclasses.dataclass(frozen=True)
class AdminUserInfo:
localpart: str
display_name: typing.Optional[str]
email: typing.Optional[str]
phone: typing.Optional[str]
roles: typing.Optional[typing.List[str]]
enabled: bool
last_active: typing.Optional[int]
deletion_request: typing.Optional[UserDeletionRequestInfo]
avatar_info: typing.List[AvatarMetadata]

@property
def has_admin_role(self) -> bool:
Expand All @@ -75,6 +125,15 @@ def from_api_response(
email=data.get("email") or None,
phone=data.get("phone") or None,
roles=roles,
enabled=data.get("enabled", True),
last_active=data.get("last_active") or None,
deletion_request=UserDeletionRequestInfo.from_api_response(
data.get("deletion_request")
),
avatar_info=[
AvatarMetadata.from_api_response(avatar_info)
for avatar_info in data.get("avatar_info", [])
],
)


Expand Down Expand Up @@ -925,6 +984,36 @@ async def update_user(
) as resp:
self._raise_error_from_response(resp)

@autosession
async def enable_user_account(
self,
localpart: str,
*,
session: aiohttp.ClientSession,
) -> None:
async with session.patch(
self._admin_v1_endpoint("/users/{}".format(localpart)),
json={
"enabled": True,
},
) as resp:
self._raise_error_from_response(resp)

@autosession
async def disable_user_account(
self,
localpart: str,
*,
session: aiohttp.ClientSession,
) -> None:
async with session.patch(
self._admin_v1_endpoint("/users/{}".format(localpart)),
json={
"enabled": False,
},
) as resp:
self._raise_error_from_response(resp)

@autosession
async def get_user_debug_info(
self,
Expand Down
57 changes: 55 additions & 2 deletions snikket_web/scss/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -708,8 +708,7 @@ input[type="submit"], button, .button {
height: 1.5em;
vertical-align: middle;
background-size: cover;
box-shadow: inset 0px 0px 0px 2px rgba(0, 0, 0, 0.2);
border-radius: $w-s4;
border-radius: 10%;

margin: 0 0.25em;

Expand Down Expand Up @@ -1068,6 +1067,10 @@ pre.guru-meditation {
}
}

label, legend {
color: $gray-800 !important;
}

.box {
background-color: black;
border-color: $gray-800;
Expand Down Expand Up @@ -1202,6 +1205,13 @@ pre.guru-meditation {
p.form-desc.weak, p.field-desc.weak {
color: $gray-700;
}

.user-badge-icon {
color: $gray-900 !important;
background-color: $gray-100 !important;
border-color: $gray-300 !important;
box-shadow: black 0 0 2px !important;
}
}

/* tooltip magic */
Expand Down Expand Up @@ -1252,3 +1262,46 @@ pre.guru-meditation {
.with-tooltip:hover:before, .with-tooltip:hover:after {
display: block;
}

.username-with-avatar {
display: flex;
align-items: center;

.avatar-container {
position: relative;

.avatar {
margin-left: 0;
}
}

.user-badge-icon {
position: absolute;
bottom: -10px;
right: 0px;
background: white;
border-radius: 50%;
width: 1.2em;
height: 1.2em;
border-color: $gray-500;
border-width: 1px;
border-style: solid;
text-align: center;
margin: 0;
padding: 0;
margin: 0;
padding: 0;
box-shadow: $gray-500 0px 0px 2px;

line-height: 1;
.icon {
/* vertical-align: text-bottom; */
padding: 0.1em;
}
}

.user-info-container {
margin-left: 0.5em;
}

}
10 changes: 10 additions & 0 deletions snikket_web/static/img/icons.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 74ecfb8

Please sign in to comment.