Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • acides/hiboo
  • frju365/hiboo
  • pascoual/hiboo
  • thedarky/hiboo
  • jeremy/hiboo
  • cyrinux/hiboo
  • a.f/hiboo
  • mickge/hiboo
  • llaq/hiboo
  • vaguelysalaried/hiboo
  • felinn-glotte/hiboo
  • AntoninDelFabbro/hiboo
  • docemmetbrown/hiboo
13 results
Show changes
Showing
with 730 additions and 0 deletions
**Bienvenue dans la documentation de Hiboo !**
/// admonition
attrs: {class: 'alert alert-info'}
**Ne vous perdez pas !** Choisissez la documentation appropriée pour votre cas
d'usage : 😉
- [Manuel d'utilisation](user_manual/index.md)
- [Manuel d'administration](admin_manual/index.md)
- [Changelog](./CHANGELOG.md)
///
---
title: Manuel d'utilisation
---
<!--TODO cherry-pick from https://acides.org/docs/hiboo/manual/-->
## Compte
### Créer un compte
1. Ouvrir le lien d'inscription dans un [navigateur web](https://www.mozilla.org/firefox/)
2. Renseigner les informations dans le formulaire
3. Cliquer sur le bouton `S'enregistrer`
![Sign up form](../../assets/img/signup.png)
## Profils
## Informations de contact
## Vous constatez un bug ?
{% if page.toc %}
<div class="col-md-3">
<nav class="sticky-top">
<h3 class="ms-2">{{ page.title }}</h3>
{% include "toc/toc.html" %}
</nav>
</div>
{% endif %}
{% raw %}
{% extends "base.html" %}
{% block title %}
<a href="/docs" class="docs-header-img d-inline-block me-2 rounded" style="">
<img src="/docs/assets/img/logo_hiboo.png" alt="logo_hiboo"/>
</a>
{% trans %}Documentation{% endtrans %}{% endblock %}
{% endraw %}
{% raw %}
{% block submenu %}
{% endraw %}
{% include "menu.html" %}
{% raw %}
{% endblock %}
{% endraw %}
{% raw %}
{% block content %}
{% endraw %}
<div class="row">
{% include "aside.html" %}
<div class="col-md-9 docs-content">
{{ page.content }}
</div>
</div>
{% include "search.html" %}
{% raw %}
{% endblock %}
{% endraw %}
{% raw %}
{% block actions %}
{% endraw %}
<a href="{{ config.repo_url }}" class="btn btn-outline-secondary">{% raw %}{% trans %}Edit on Gitlab{% endtrans %}{% endraw %}</a>
{% raw %}
{% endblock %}
{% endraw %}
<nav class="navbar navbar-expand-lg">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#docsNavbarNav"
aria-controls="docsNavbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="docsNavbarNav">
<ul class="navbar-nav me-auto">
{% for nav_item in nav %}
{% if nav_item.children %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ nav_item.title }}
</a>
<ul class="dropdown-menu">
{% for nav_item in nav_item.children %}
<li {% if nav_item.active %}current{% endif %}>
<a class="dropdown-item" href="{{ nav_item.url|url }}">{{ nav_item.title }}</a>
</li>
{% endfor %}
</ul>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link {% if nav_item.active %}current{% endif %}" href="{{ nav_item.url|url }}">{{ nav_item.title }}</a>
</li>
{% endif %}
{% endfor %}
<li class="nav-item ml-3">
<a href="#" class="nav-link" data-bs-toggle="modal" data-bs-target="#mkdocs_search_modal">
{% raw %}{{ render_icon("search") }}{% endraw %}
{% trans %}Search{% endtrans %}
</a>
</li>
</ul>
</div>
</nav>
<script>var base_url = {{ base_url|tojson }};</script>
{%- for script in config.extra_javascript %}
{{ script|script_tag }}
{%- endfor %}
<div class="modal docs-search" id="mkdocs_search_modal" tabindex="-1" role="dialog" aria-labelledby="searchModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" id="searchModalLabel">{% trans %}Search{% endtrans %}</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% trans %}Close{% endtrans %}"></button>
</div>
<div class="modal-body">
<p>{% trans %}From here you can search these documents. Enter your search terms below.{% endtrans %}</p>
<form>
<div class="form-group">
<input type="search" class="form-control mb-3" placeholder="{% trans %}Search...{% endtrans %}" id="mkdocs-search-query" title="{% trans %}Type search term here{% endtrans %}">
</div>
</form>
<div id="mkdocs-search-results" data-no-results-text="{% trans %}No results found{% endtrans %}"></div>
</div>
<div class="modal-footer">
</div>
</div>
</div>
</div>
<li>
<a class="d-inline-block rounded docs-side ds-link ms-2" href="{{ toc_item.url }}">
{{ toc_item.title }}
</a>
{% if toc_item.children %}
<ul class="list-unstyled ms-4">
{% for toc_item in toc_item.children %}
{% include "toc/toc-item.html" %}
{% endfor %}
</ul>
{% endif %}
</li>
<ul class="list-unstyled small">
{% for toc_item in page.toc %}
{% include "toc/toc-item.html" %}
{% endfor %}
</ul>
import flask
from flask_bootstrap import Bootstrap5
from werkzeug.middleware import proxy_fix
from trurt import models, manage, configuration, debug, utils
from hiboo import models, configuration, debug, utils
def create_app_from_config(config):
""" Create a new application based on the given configuration
"""
app = flask.Flask(__name__)
app.cli.add_command(manage.trurt)
app.wsgi_app = proxy_fix.ProxyFix(app.wsgi_app)
# Initialize flask_bootstrap
bootstrap = Bootstrap5(app)
# Initialize application extensions
config.init_app(app)
models.db.init_app(app)
utils.limiter.init_app(app)
utils.babel.init_app(app)
utils.translation.init_app(app, locale_selector=utils.get_locale)
utils.login.init_app(app)
utils.login.user_loader(models.User.get)
utils.migrate.init_app(app, models.db)
# Initialize cache
cache_config = config.get_config_by_prefix('CACHE_')
utils.cache.init_app(app, config=cache_config)
# Initialize debugging tools
if app.config.get("DEBUG"):
debug.toolbar.init_app(app)
......@@ -27,13 +36,24 @@ def create_app_from_config(config):
# Inject the default variables in the Jinja parser
@app.context_processor
def inject_defaults():
return dict(config=app.config)
return dict(config=app.config, utils=utils)
# Import views
from trurt import account, admin, sso
from hiboo import account, user, group, profile, moderation, service, application, sso, api, docs
app.register_blueprint(account.blueprint, url_prefix='/account')
app.register_blueprint(user.blueprint, url_prefix='/user')
app.register_blueprint(group.blueprint, url_prefix='/group')
app.register_blueprint(profile.blueprint, url_prefix='/profile')
app.register_blueprint(service.blueprint, url_prefix='/service')
app.register_blueprint(application.blueprint, url_prefix='/application')
app.register_blueprint(moderation.blueprint, url_prefix='/moderation')
app.register_blueprint(sso.blueprint, url_prefix='/sso')
app.register_blueprint(admin.blueprint, url_prefix='/admin')
app.register_blueprint(api.blueprint, url_prefix='/api')
app.register_blueprint(docs.blueprint, url_prefix='/docs')
# Enable global CLI
from hiboo import cli
app.cli.add_command(cli.tasks)
@app.route("/")
def index():
......
import flask
blueprint = flask.Blueprint("account", __name__, template_folder="templates")
from hiboo.account import login, home, settings
from hiboo.captcha import captcha
from hiboo.format import NameFormat
from wtforms import validators, fields
from flask_babel import lazy_gettext as _
from flask_bootstrap import SwitchField
import flask_wtf
class LoginForm(flask_wtf.FlaskForm):
username = fields.StringField(_('Username'), [validators.DataRequired()])
password = fields.PasswordField(_('Password'), [validators.DataRequired()])
remember_me = SwitchField(_('Remember me'), default=False)
submit = fields.SubmitField(_('Sign in'))
class TotpForm(flask_wtf.FlaskForm):
totp = fields.PasswordField(_('Enter the one-time password delivered by your client'), [validators.DataRequired()], render_kw={'autocomplete': 'one-time-code'})
submit = fields.SubmitField(_('Confirm'))
class SignupForm(flask_wtf.FlaskForm):
formatter = NameFormat.registry["lowercase"]
username = fields.StringField(
_('Username'),
formatter.validators(),
description = (_("The username can be between 3 and 30 characters long. {}".format(formatter.message)))
)
password = fields.PasswordField(_('Password'), [validators.DataRequired()])
password2 = fields.PasswordField(_('Confirm password'),
[validators.DataRequired(), validators.EqualTo('password')])
captcha = captcha.GeneratedTextImageCaptchaField(_('Prove that you are human, copy the following text'))
submit = fields.SubmitField(_('Sign up'))
class PasswordForm(flask_wtf.FlaskForm):
old = fields.PasswordField(_('Old password'), [validators.DataRequired()])
password = fields.PasswordField(_('New password'), [validators.DataRequired()])
password2 = fields.PasswordField(_('Confirm new password'),
[validators.DataRequired(), validators.EqualTo('password')])
submit = fields.SubmitField(_('Change password'))
class ContactForm(flask_wtf.FlaskForm):
email = fields.StringField(_("Email address"), [validators.Email(), validators.Optional()])
matrix = fields.StringField(_("Matrix ID"), [validators.Optional()])
submit = fields.SubmitField(_("Update contact info"))
import enum
from hiboo.account import blueprint
from hiboo.profile import common
from hiboo import security, models
import flask
import flask_login
@blueprint.route("/home")
@security.authentication_required()
def home():
user = flask_login.current_user
page = flask.request.args.get('page', 1, type=int)
events = user.history.filter(models.History.user == user).order_by(
models.History.created_at.desc()).paginate(page=page, per_page=25)
# TODO also query user.groups non individual events (renamed or removed group)
return flask.render_template("account_home.html", events=events)
@blueprint.route("/profiles")
@security.authentication_required()
def profiles():
user = flask_login.current_user
profiles = []
for service in models.Service.query.all():
profiles.extend([
(service, profile) for profile in
models.Profile.filter(service, user).all()
])
return flask.render_template("account_profiles.html", profiles=profiles, common=common)
from hiboo import models, utils, security
from hiboo.account import blueprint, forms
from flask_babel import lazy_gettext as _
from flask import session
from authlib.jose import JsonWebToken
import datetime
import flask_login
import flask
@blueprint.route("/signin/password", methods=["GET", "POST"])
def signin_password():
form = forms.LoginForm()
if form.validate_on_submit():
user = models.User.login(form.username.data, form.password.data)
if form.remember_me.data is True:
session.permanent = True
if user and models.Auth.TOTP in user.auths and user.auths[models.Auth.TOTP].enabled:
session["username"] = user.name
return flask.redirect(utils.url_for(".signin_totp"))
elif user:
flask_login.login_user(user)
return flask.redirect(utils.url_or_intent(".home"))
else:
flask.flash(_("Wrong credentials"), "danger")
return flask.render_template("account_signin_password.html", form=form,
action=utils.url_for(".signin_password"))
@blueprint.route("/signin/totp", methods=["GET", "POST"])
def signin_totp():
form = forms.TotpForm()
username = session.get("username") or flask.abort(403)
user = models.User.query.filter_by(name=username).first() or flask.abort(403)
if form.validate_on_submit():
if user.auths[models.Auth.TOTP].check_totp(form.totp.data):
flask_login.login_user(user)
session.pop("username")
return flask.redirect(utils.url_or_intent(".home"))
else:
flask.flash(_("Wrong TOTP"), "danger")
return flask.render_template("account_signin_totp.html", form=form)
@blueprint.route("/signout")
@security.authentication_required()
def signout():
flask_login.logout_user()
return flask.redirect(flask.url_for(".signin_password"))
@blueprint.route("/signup", methods=["GET", "POST"])
def signup():
if not flask.current_app.config['OPEN_SIGNUP']:
token = flask.request.args.get('token') or flask.abort(403)
key = flask.current_app.config["SECRET_KEY"]
jwt = JsonWebToken(['HS512'])
claims_options = {
'exp': {'essential': True, 'value': datetime.datetime.now().timestamp()},
'aud': {'essential': True, 'value': flask.url_for('.signup')}
}
try:
claims = jwt.decode(token, key, claims_options=claims_options)
claims.validate()
except Exception as e:
flask.flash(_("Invalid or expired signup link"), "danger")
return flask.redirect(flask.url_for(".signin_password"))
form = forms.SignupForm()
if form.validate_on_submit():
conflict = models.User.query.filter_by(username=form.username.data).first()
if conflict:
flask.flash(_("A user with the same username exists already"), "danger")
else:
user = models.User()
user.name = form.username.data
auth = models.Auth(models.Auth.PASSWORD, enabled=True)
auth.set_password(form.password.data)
user.auths = {models.Auth.PASSWORD: auth}
models.db.session.add(user)
models.db.session.add(auth)
models.log(models.History.SIGNUP,
comment=str(_("Signed up using the Web form")), user=user)
models.db.session.commit()
flask.flash(_("User created successfully"), "success")
flask_login.login_user(user)
return flask.redirect(utils.url_or_intent(".home"))
return flask.render_template("account_signup.html", form=form)
@blueprint.route("/auth/password/reset", methods=["GET", "POST"])
def password_reset():
token = flask.request.args.get('token') or flask.abort(403)
key = flask.current_app.config["SECRET_KEY"]
jwt = JsonWebToken(['HS512'])
claims_options = {
'exp': {'essential': True, 'value': datetime.datetime.now().timestamp()},
'aud': {'essential': True, 'value': flask.url_for('.password_reset')},
'user_uuid': {'essential': True}
}
try:
claims = jwt.decode(token, key, claims_options=claims_options)
claims.validate()
user = models.User.query.get(claims["user_uuid"]) or flask.abort(404)
except Exception as e:
flask.flash(_("Invalid or expired reset link"), "danger")
return flask.redirect(flask.url_for(".signin_password"))
form = forms.PasswordForm()
del form.old
if form.validate_on_submit():
auth = user.auths[models.Auth.PASSWORD]
auth.set_password(form.password.data)
models.log(models.History.PASSWORD, user=user)
models.db.session.add(auth)
models.db.session.commit()
flask.flash(_("Successfully reset your password"), "success")
return flask.redirect(flask.url_for(".signin_password"))
return flask.render_template("account_auth_password_reset.html", form=form)
@blueprint.route("/auth/totp/reset", methods=["GET", "POST"])
def totp_reset():
token = flask.request.args.get('token') or flask.abort(403)
key = flask.current_app.config["SECRET_KEY"]
jwt = JsonWebToken(['HS512'])
claims_options = {
'exp': {'essential': True, 'value': datetime.datetime.now().timestamp()},
'aud': {'essential': True, 'value': flask.url_for('.totp_reset')},
'user_uuid': {'essential': True}
}
try:
claims = jwt.decode(token, key, claims_options=claims_options)
claims.validate()
user = models.User.query.get(claims["user_uuid"]) or flask.abort(404)
except Exception as e:
flask.flash(_("Invalid or expired reset link"), "danger")
return flask.redirect(flask.url_for(".signin_password"))
form = forms.LoginForm()
if form.validate_on_submit():
user = models.User.login(form.username.data, form.password.data)
if user:
flask_login.login_user(user)
if form.remember_me.data is True:
session.permanent = True
return flask.redirect(flask.url_for(".totp_disable"))
else:
flask.flash(_("Wrong credentials"), "danger")
return flask.render_template("account_signin_password.html", token=token, form=form)
from hiboo.account import blueprint, forms
from hiboo import models, security
from wtforms import fields
from flask_babel import lazy_gettext as _
from io import BytesIO
import flask
import flask_login
import pyotp
import qrcode
import base64
@blueprint.route("/auth/password", methods=["GET", "POST"])
@security.authentication_required()
def password():
form = forms.PasswordForm()
if form.validate_on_submit():
auth = flask_login.current_user.auths[models.Auth.PASSWORD]
if auth.check_password(form.old.data):
auth.set_password(form.password.data)
models.log(models.History.PASSWORD, user=flask_login.current_user)
models.db.session.add(auth)
models.db.session.commit()
flask.flash(_("Successfully reset your password"), "success")
return flask.redirect(flask.url_for(".home"))
else:
flask.flash(_("Wrong credentials, check your old password"), "danger")
return flask.render_template("account_auth_password.html", form=form)
@blueprint.route("/auth/totp", methods=["GET", "POST"])
@security.authentication_required()
def totp():
user = flask_login.current_user
form = forms.TotpForm()
if form.validate_on_submit():
if user.auths[models.Auth.TOTP].check_totp(form.totp.data):
flask.flash(_("TOTP is valid"), "success")
return flask.redirect(flask.url_for(".totp"))
else:
flask.flash(_("Invalid or expired TOTP"), "danger")
return flask.redirect(flask.url_for(".totp"))
enabled = models.Auth.TOTP in user.auths and user.auths[models.Auth.TOTP].enabled
return flask.render_template("account_auth_totp.html", form=form, enabled=enabled)
@blueprint.route("/auth/totp/enable", methods=["GET", "POST"])
@security.authentication_required()
def totp_enable():
user = flask_login.current_user
if models.Auth.TOTP not in user.auths:
auth = models.Auth(models.Auth.TOTP, enabled=False)
auth.set_otp_key()
user.auths[models.Auth.TOTP] = auth
models.db.session.add(auth)
models.db.session.commit()
key = user.auths[models.Auth.TOTP].value
issuer = flask.current_app.config['WEBSITE_NAME']
totp_uri = pyotp.totp.TOTP(key).provisioning_uri(
name=user.name,
issuer_name=issuer)
img = qrcode.make(totp_uri).get_image()
buffered = BytesIO()
img.save(buffered, format="PNG")
qr = base64.b64encode(buffered.getvalue()).decode('ascii')
form = forms.TotpForm()
if form.validate_on_submit():
if user.auths[models.Auth.TOTP].check_totp(form.totp.data):
models.log(models.History.MFA, comment=str(_("TOTP has been enabled")),
user=user)
user.auths[models.Auth.TOTP].enabled = True
models.db.session.add(user)
models.db.session.commit()
flask.flash(_("Successfully enabled TOTP"), "success")
return flask.redirect(flask.url_for(".totp"))
else:
flask.flash(_("Failed to enable TOTP, wrong TOTP"), "danger")
return flask.redirect(flask.url_for(".totp_enable"))
flask.flash(_("Scan this QR code or use the informations below it to configure your TOTP client"), "info")
return flask.render_template(
"account_auth_totp_enable.html",
key=key, name=user.name, issuer=issuer, qr=qr, form=form
)
@blueprint.route("/auth/totp/disable", methods=["GET", "POST"])
@security.authentication_required()
@security.confirmation_required(_("disable TOTP"))
def totp_disable():
user = flask_login.current_user
auth = user.auths[models.Auth.TOTP]
models.log(models.History.MFA, comment=str(_("TOTP has been disabled")),
user=flask_login.current_user)
models.db.session.delete(auth)
models.db.session.commit()
flask.flash(_("Successfully disabled TOTP"), "success")
return flask.redirect(flask.url_for(".totp"))
@blueprint.route("/contact", methods=["GET", "POST"])
@security.authentication_required()
def contact():
user = flask_login.current_user
form = forms.ContactForm()
if form.validate_on_submit():
user.contact = {
field.name: form.data[field.name] for field in form
if (type(field) is fields.StringField and form.data[field.name])
}
models.db.session.add(user)
models.db.session.commit()
flask.flash(_("Successfully updated your contact info"), "success")
if user.contact:
form.process(**user.contact)
return flask.render_template("account_contact.html", form=form)
{% extends "form.html" %}
{% block title %}
{% trans %}New password{% endtrans %}
{% endblock %}
{% extends "form.html" %}
{% block title %}
{% trans %}Reset your password{% endtrans %}
{% endblock %}
{% extends "base.html" %}
{% block title %}
{% trans %}Two-factor authentication (2FA){% endtrans %}
{% endblock %}
{% block subtitle %}
{% trans %}with time-based one-time password (TOTP){% endtrans %}
{% endblock %}
{% block content %}
<div class="col-md-6 col">
<p>{% trans %}TOTP is an optional secondary layer of the authentication
process used to enforce the protection of your account with a one-time
password. You can read
<a href="https://en.wikipedia.org/wiki/Time-based_one-time_password">this
Wikipedia page</a>
if you want to learn more about this mechanism.{% endtrans %}</p>
</div>
{% if enabled %}
<div class="col-md-6 col">
<div class="alert alert-success">
<h5 class="alert-heading">{% trans %}Two-factor authentication is enabled{% endtrans %}</h5>
<p>{% trans %}Click on <i>Disable TOTP</i> to disable it{% endtrans %}</p>
</div>
<div class="card">
<div class="card-body">
<h5>{% trans %}Test your one-time password{% endtrans %}</h5>
<p>{% trans %}Feel free to use this form in order to check your client configuration{% endtrans %}</p>
</div>
<div class="card-footer">
{{ render_form(form) }}
</div>
</div>
{% else %}
<div class="col-md-6 col">
<div class="alert alert-info">
<h5 class="alert-heading">{% trans %}Two-factor authentication is disabled{% endtrans %}</h5>
<p>{% trans %}Click on <i>Enable TOTP</i> to configure it{% endtrans %}</p>
</div>
<div class="alert alert-warning">
<h5 class="alert-heading">{% trans %}Attention{% endtrans %}</h5>
<p>{% trans %}You will need a working TOTP client in order to complete
this configuration. Several open-source apps can help you for this
(and some on mobile are available on
<a href="https://search.f-droid.org/?q=totp&lang=fr">
F-Droid</a>){% endtrans %}</p>
</div>
</div>
{% endif %}
{% endblock %}
{% block actions %}
{% if enabled %}
<a href="{{ url_for(".totp_disable") }}" class="btn btn-outline-warning">{% trans %}Disable TOTP{% endtrans %}</a>
{% else %}
<a href="{{ url_for(".totp_enable") }}" class="btn btn-outline-info">{% trans %}Enable TOTP{% endtrans %}</a>
{% endif %}
{% endblock %}
{% extends "base.html" %}
{% block title %}
{% trans %}Two-factor authentication (2FA){% endtrans %}
{% endblock %}
{% block subtitle %}
{% trans %}with time-based one-time password (TOTP){% endtrans %}
{% endblock %}
{% block content %}
<div class="col-md-6 col">
<div class="card">
<img class="card-img-top w-50 mx-auto p-2"
src="data:image/png;base64,{{ qr }}"
alt="TOTP QR code"/>
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
<b>{% trans %}Secret key{% endtrans %}</b> <code>{{ key }}</code>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
<b>{% trans %}Name{% endtrans %}</b> <code>{{ name }}</code>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
<b>{% trans %}Issuer{% endtrans %}</b> <code>{{ issuer }}</code>
</li>
</ul>
<div class="card-footer">
{{ render_form(form) }}
</div>
</div>
</div>
{% endblock %}
{% block actions %}
<a href="{{ url_for(".totp_disable") }}" class="btn btn-outline-warning">{% trans %}Cancel{% endtrans %}</a>
{% endblock %}
{% extends "form.html" %}
{% block title %}
{% trans %}Update contact info{% endtrans %}
{% endblock %}
{% extends "base.html" %}
{% block title %}
{% trans %}My account{% endtrans %}
{% endblock %}
{% block subtitle %}
{% trans %}status and history{% endtrans %}
{% endblock %}
{% block content %}
<div class="row">
<div class="col-12 col-xl-6">
<section class="content">
<div class="row">
<div class="col-md-6 col">
{{ macros.infobox(_("Account age"), "{} days".format((current_user.created_at.today() - current_user.created_at).total_seconds() // 86400), "primary", "calendar-event-fill") }}
</div>
<div class="col-md-6 col">
{{ macros.infobox(_("Profile count"), current_user.profiles.filter_by(status="active").count(), "danger", "person-vcard-fill") }}
</div>
</div>
<div class="row">
<div class="col-md-6 col">
{{ macros.infobox(_("Pending requests"), current_user.profiles.filter_by(status="request").count(), "success", "hourglass-split") }}
</div>
<div class="col-md-6 col">
{{ macros.infobox(_("Role"), _("administrator") if current_user.is_admin else _("registered user"), "warning", "person-badge-fill") }}
</div>
</div>
{% if current_user.groups %}
<div class="row">
<div class="col-md-6 col">
{{ macros.infobox(_("Membership"), current_user.groups | map(attribute='groupname') | join(', '), "purple", "diagram-3-fill") }}
</div>
</div>
{% endif %}
</section>
</div>
<div class="col-12 col-xl-6">
{{ macros.timeline(events) }}
</div>
</div>
{% endblock %}