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 461 additions and 112 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 werkzeug.contrib import fixers
from flask_bootstrap import Bootstrap5
from werkzeug.middleware import proxy_fix
from hiboo import models, configuration, debug, utils
......@@ -8,7 +9,10 @@ def create_app_from_config(config):
""" Create a new application based on the given configuration
"""
app = flask.Flask(__name__)
app.wsgi_app = fixers.ProxyFix(app.wsgi_app)
app.wsgi_app = proxy_fix.ProxyFix(app.wsgi_app)
# Initialize flask_bootstrap
bootstrap = Bootstrap5(app)
# Initialize application extensions
config.init_app(app)
......@@ -18,7 +22,10 @@ def create_app_from_config(config):
utils.login.init_app(app)
utils.login.user_loader(models.User.get)
utils.migrate.init_app(app, models.db)
utils.redis.init_app(app)
# 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"):
......@@ -32,14 +39,17 @@ def create_app_from_config(config):
return dict(config=app.config, utils=utils)
# Import views
from hiboo import account, user, profile, service, application, sso, api
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(api.blueprint, url_prefix='/api')
app.register_blueprint(docs.blueprint, url_prefix='/docs')
# Enable global CLI
from hiboo import cli
......
......@@ -3,4 +3,4 @@ import flask
blueprint = flask.Blueprint("account", __name__, template_folder="templates")
from hiboo.account import login, home, settings
\ No newline at end of file
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 = fields.BooleanField(_('Remember me'), default=False)
remember_me = SwitchField(_('Remember me'), default=False)
submit = fields.SubmitField(_('Sign in'))
class TotpForm(flask_wtf.FlaskForm):
totp = fields.PasswordField(_('Time-based One-Time Password'), [validators.DataRequired()])
submit = fields.SubmitField(_('Validate'))
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):
username = fields.StringField(_('Username'), [
validators.DataRequired(),
validators.Regexp("[a-z0-9-_]", message="Your username must be \
comprised of lowercase letters and numbers only")
])
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')
captcha = captcha.GeneratedTextImageCaptchaField(_('Prove that you are human, copy the following text'))
submit = fields.SubmitField(_('Sign up'))
......@@ -38,6 +41,6 @@ class PasswordForm(flask_wtf.FlaskForm):
class ContactForm(flask_wtf.FlaskForm):
email = fields.StringField(_("Email address"), [validators.Email()])
matrix = fields.StringField(_("Matrix ID"), [])
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
......@@ -9,7 +10,12 @@ import flask_login
@blueprint.route("/home")
@security.authentication_required()
def home():
return flask.render_template("account_home.html")
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")
......@@ -22,4 +28,4 @@ def profiles():
(service, profile) for profile in
models.Profile.filter(service, user).all()
])
return flask.render_template("account_profiles.html", profiles=profiles, common=common)
\ No newline at end of file
return flask.render_template("account_profiles.html", profiles=profiles, common=common)
......@@ -8,30 +8,30 @@ import datetime
import flask_login
import flask
@blueprint.route("/signin", methods=["GET", "POST"])
def signin():
@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 user and models.Auth.TOTP in user.auths:
session["username"] = user.username
return flask.redirect(flask.url_for(".totp_verify"))
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)
if form.remember_me.data == True:
session.permanent = True
return flask.redirect(utils.url_or_intent(".home"))
else:
flask.flash(_("Wrong credentials"), "danger")
return flask.render_template("account_signin.html", form=form,
action=utils.url_for(".signin"))
return flask.render_template("account_signin_password.html", form=form,
action=utils.url_for(".signin_password"))
@blueprint.route("/totp/verify", methods=["GET", "POST"])
def totp_verify():
@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(username=username).first() 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)
......@@ -39,14 +39,14 @@ def totp_verify():
return flask.redirect(utils.url_or_intent(".home"))
else:
flask.flash(_("Wrong TOTP"), "danger")
return flask.render_template("account_totp_verify.html", form=form)
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"))
return flask.redirect(flask.url_for(".signin_password"))
@blueprint.route("/signup", methods=["GET", "POST"])
......@@ -64,7 +64,7 @@ def signup():
claims.validate()
except Exception as e:
flask.flash(_("Invalid or expired signup link"), "danger")
return flask.redirect(flask.url_for(".signin"))
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()
......@@ -72,8 +72,8 @@ def signup():
flask.flash(_("A user with the same username exists already"), "danger")
else:
user = models.User()
user.username = form.username.data
auth = models.Auth(models.Auth.PASSWORD)
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)
......@@ -86,23 +86,60 @@ def signup():
return flask.redirect(utils.url_or_intent(".home"))
return flask.render_template("account_signup.html", form=form)
@blueprint.route("/reset/<token_uuid>", methods=["GET", "POST"])
def reset(token_uuid):
token = models.ResetToken.query.get(token_uuid) or flask.abort(403)
if token.updated_at is not None or token.expired_at < datetime.datetime.now():
@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"))
return flask.redirect(flask.url_for(".signin_password"))
form = forms.PasswordForm()
del form.old
if form.validate_on_submit():
token.expired_at = datetime.datetime.now()
models.db.session.add(token)
auth = token.user.auths[models.Auth.PASSWORD]
auth = user.auths[models.Auth.PASSWORD]
auth.set_password(form.password.data)
models.log(models.History.PASSWORD, user=token.user)
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"))
return flask.render_template("account_reset.html", form=form)
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)
......@@ -11,7 +11,7 @@ import qrcode
import base64
@blueprint.route("/password", methods=["GET", "POST"])
@blueprint.route("/auth/password", methods=["GET", "POST"])
@security.authentication_required()
def password():
form = forms.PasswordForm()
......@@ -26,50 +26,68 @@ def password():
return flask.redirect(flask.url_for(".home"))
else:
flask.flash(_("Wrong credentials, check your old password"), "danger")
return flask.render_template("account_password.html", form=form)
return flask.render_template("account_auth_password.html", form=form)
@blueprint.route("/totp", methods=["GET", "POST"])
@blueprint.route("/auth/totp", methods=["GET", "POST"])
@security.authentication_required()
def totp():
user = flask_login.current_user
if models.Auth.TOTP in user.auths:
key = user.auths[models.Auth.TOTP].value
issuer = flask.current_app.config['WEBSITE_NAME']
totp_uri = pyotp.totp.TOTP(key).provisioning_uri(
name=user.username,
issuer_name=issuer)
img = qrcode.make(totp_uri).get_image()
buffered = BytesIO()
img.save(buffered, format="PNG")
qr = base64.b64encode(buffered.getvalue()).decode('ascii')
return flask.render_template(
"account_totp.html",
key=key, name=user.username, issuer=issuer, qr=qr
)
return flask.render_template("account_totp.html")
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("/totp/setup", methods=["GET", "POST"])
@blueprint.route("/auth/totp/enable", methods=["GET", "POST"])
@security.authentication_required()
@security.confirmation_required("setup TOTP")
def totp_setup():
def totp_enable():
user = flask_login.current_user
auth = models.Auth(models.Auth.TOTP)
auth.set_otp_key()
user.auths[models.Auth.TOTP] = auth
models.log(models.History.MFA, comment=str(_("TOTP has been enabled")),
user=flask_login.current_user)
models.db.session.add(auth)
models.db.session.commit()
flask.flash(_("Successfully setup TOTP"), "success")
return flask.redirect(flask.url_for(".totp"))
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("/totp/delete", methods=["GET", "POST"])
@blueprint.route("/auth/totp/disable", methods=["GET", "POST"])
@security.authentication_required()
@security.confirmation_required("disable TOTP")
def totp_delete():
@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")),
......@@ -88,7 +106,7 @@ def contact():
if form.validate_on_submit():
user.contact = {
field.name: form.data[field.name] for field in form
if type(field) is fields.StringField
if (type(field) is fields.StringField and form.data[field.name])
}
models.db.session.add(user)
models.db.session.commit()
......
{% 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 %}
{% block content %}
{{ macros.form(form) }}
{% endblock %}
\ No newline at end of file
{% 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 title %}
{% trans %}My account{% endtrans %}
{% endblock %}
{% block subtitle %}
{% trans %}status and history{% endtrans %}
{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6 col">
<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), "blue", "calendar") }}
</div>
<div class="col-md-6 col">
{{ macros.infobox(_("Profile count"), current_user.profiles.filter_by(status="active").count(), "red", "users") }}
<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>
<div class="row">
<div class="col-md-6 col">
{{ macros.infobox(_("Pending requests"), current_user.profiles.filter_by(status="request").count(), "green", "hourglass") }}
<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>
<div class="col-md-6 col">
{{ macros.infobox(_("Role"), _("administrator") if current_user.is_admin else _("registered user"), "yellow", "lock") }}
{% 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>
</div>
</section>
{% endif %}
</section>
</div>
<div class="col-12 col-xl-6">
{{ macros.timeline(events) }}
</div>
</div>
<div class="col-md-6 col">
{{ macros.timeline(current_user.history) }}
</div>
</div>
{% endblock %}