You've already forked snikket-web-portal
Massive code cleanup
- Avoid fighting import cycles using a factory function - Collapse useless subpackages into simple modules - Move flask plugins / infrastructure in own module - Refactor how blueprints are used to localize information about URL routing to app factory
This commit is contained in:
2
.envrc
2
.envrc
@@ -1,5 +1,5 @@
|
|||||||
layout python3
|
layout python3
|
||||||
|
|
||||||
export QUART_APP=snikket_web:app
|
export QUART_APP='snikket_web:create_app()'
|
||||||
export QUART_ENV=development
|
export QUART_ENV=development
|
||||||
export SNIKKET_WEB_CONFIG="$(pwd)/.local/web_config.py"
|
export SNIKKET_WEB_CONFIG="$(pwd)/.local/web_config.py"
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
exec hypercorn -b "0.0.0.0:8000" 'snikket_web:create_app()'
|
||||||
exec hypercorn -b "0.0.0.0:8000" snikket_web:app
|
|
||||||
|
|||||||
4
mypy.ini
4
mypy.ini
@@ -6,13 +6,13 @@ disallow_untyped_calls = True
|
|||||||
disallow_untyped_defs = True
|
disallow_untyped_defs = True
|
||||||
disallow_incomplete_defs = True
|
disallow_incomplete_defs = True
|
||||||
#check_untyped_defs = True
|
#check_untyped_defs = True
|
||||||
disallow_untyped_decorators = False
|
disallow_untyped_decorators = True
|
||||||
#disallow_any_unimported = True
|
#disallow_any_unimported = True
|
||||||
#disallow_any_expr = True
|
#disallow_any_expr = True
|
||||||
#disallow_any_decorated = True
|
#disallow_any_decorated = True
|
||||||
disallow_any_explicit = False
|
disallow_any_explicit = False
|
||||||
#disallow_any_generics = True
|
#disallow_any_generics = True
|
||||||
disallow_subclassing_any = False
|
disallow_subclassing_any = True
|
||||||
no_implicit_optional = True
|
no_implicit_optional = True
|
||||||
warn_redundant_casts = True
|
warn_redundant_casts = True
|
||||||
warn_unused_ignores = True
|
warn_unused_ignores = True
|
||||||
|
|||||||
@@ -1,155 +1,25 @@
|
|||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
import itertools
|
|
||||||
import logging
|
import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
import quart.flask_patch
|
import quart.flask_patch
|
||||||
|
|
||||||
|
import quart
|
||||||
from quart import (
|
from quart import (
|
||||||
Quart, request, render_template, redirect, url_for, Response,
|
url_for,
|
||||||
current_app,
|
|
||||||
)
|
)
|
||||||
import quart.exceptions
|
|
||||||
|
|
||||||
from flask_wtf import FlaskForm
|
|
||||||
import wtforms
|
|
||||||
import flask_babel
|
|
||||||
from flask_babel import Babel, _, lazy_gettext as _l
|
|
||||||
|
|
||||||
from . import colour, xmpputil
|
|
||||||
from .prosodyclient import client
|
|
||||||
|
|
||||||
|
from . import colour, infra
|
||||||
from ._version import version, version_info # noqa:F401
|
from ._version import version, version_info # noqa:F401
|
||||||
|
|
||||||
app = Quart(__name__)
|
|
||||||
app.config.setdefault("LANGUAGES", ["de", "en"])
|
|
||||||
app.config.from_envvar("SNIKKET_WEB_CONFIG")
|
|
||||||
|
|
||||||
client.init_app(app)
|
|
||||||
client.default_login_redirect = "login"
|
|
||||||
|
|
||||||
babel = Babel(app)
|
|
||||||
|
|
||||||
|
|
||||||
class LoginForm(FlaskForm):
|
|
||||||
address = wtforms.TextField(
|
|
||||||
_l("Address"),
|
|
||||||
validators=[wtforms.validators.InputRequired()],
|
|
||||||
)
|
|
||||||
|
|
||||||
password = wtforms.PasswordField(
|
|
||||||
_l("Password"),
|
|
||||||
validators=[wtforms.validators.InputRequired()],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@babel.localeselector
|
|
||||||
def selected_locale() -> str:
|
|
||||||
return request.accept_languages.best_match(
|
|
||||||
current_app.config['LANGUAGES']
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/login", methods=["GET", "POST"])
|
|
||||||
async def login() -> typing.Union[str, quart.Response]:
|
|
||||||
if client.has_session and (await client.test_session()):
|
|
||||||
return redirect(url_for('user.index'))
|
|
||||||
|
|
||||||
form = LoginForm()
|
|
||||||
if form.validate_on_submit():
|
|
||||||
jid = form.address.data
|
|
||||||
localpart, domain, resource = xmpputil.split_jid(jid)
|
|
||||||
if not localpart:
|
|
||||||
localpart, domain = domain, current_app.config["SNIKKET_DOMAIN"]
|
|
||||||
jid = "{}@{}".format(localpart, domain)
|
|
||||||
password = form.password.data
|
|
||||||
try:
|
|
||||||
await client.login(jid, password)
|
|
||||||
except quart.exceptions.Unauthorized:
|
|
||||||
form.password.errors.append(
|
|
||||||
_("Invalid user name or password.")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return redirect(url_for('user.index'))
|
|
||||||
|
|
||||||
return await render_template("login.html", form=form)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
|
||||||
async def home() -> quart.Response:
|
|
||||||
if client.has_session:
|
|
||||||
return redirect(url_for('user.index'))
|
|
||||||
|
|
||||||
return redirect(url_for('login'))
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/meta/about.html")
|
|
||||||
async def about() -> str:
|
|
||||||
return await render_template("about.html", version=version)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/meta/demo.html")
|
|
||||||
async def demo() -> str:
|
|
||||||
return await render_template("demo.html")
|
|
||||||
|
|
||||||
|
|
||||||
def repad(s: str) -> str:
|
|
||||||
return s + "=" * (4 - len(s) % 4)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/avatar/<from_>/<code>")
|
|
||||||
async def avatar(from_: str, code: str) -> quart.Response:
|
|
||||||
try:
|
|
||||||
etag = request.headers["if-none-match"]
|
|
||||||
except KeyError:
|
|
||||||
etag = None
|
|
||||||
|
|
||||||
address = base64.urlsafe_b64decode(repad(from_)).decode("utf-8")
|
|
||||||
info = await client.get_avatar(address, metadata_only=True)
|
|
||||||
bin_hash = binascii.a2b_hex(info["sha1"])
|
|
||||||
new_etag = base64.urlsafe_b64encode(bin_hash).decode("ascii").rstrip("=")
|
|
||||||
|
|
||||||
cache_ttl = timedelta(seconds=current_app.config.get(
|
|
||||||
"AVATAR_CACHE_TTL",
|
|
||||||
300,
|
|
||||||
))
|
|
||||||
|
|
||||||
response = Response("", mimetype=info["type"])
|
|
||||||
response.headers["etag"] = new_etag
|
|
||||||
# XXX: It seems to me that quart expects localtime(?!) in this field...
|
|
||||||
response.expires = datetime.now() + cache_ttl
|
|
||||||
response.headers["Content-Security-Policy"] = \
|
|
||||||
"frame-ancestors 'none'; default-src 'none'; style-src 'unsafe-inline'"
|
|
||||||
|
|
||||||
if etag is not None and new_etag == etag:
|
|
||||||
response.status_code = 304
|
|
||||||
return response
|
|
||||||
|
|
||||||
data = await client.get_avatar_data(address, info["sha1"])
|
|
||||||
if data is None:
|
|
||||||
response.status_code = 404
|
|
||||||
return response
|
|
||||||
|
|
||||||
response.status_code = 200
|
|
||||||
|
|
||||||
if request.method == "HEAD":
|
|
||||||
response.content_length = len(data)
|
|
||||||
return response
|
|
||||||
|
|
||||||
response.set_data(data)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@app.context_processor
|
|
||||||
def proc() -> typing.Dict[str, typing.Any]:
|
def proc() -> typing.Dict[str, typing.Any]:
|
||||||
def url_for_avatar(entity: str, hash_: str,
|
def url_for_avatar(entity: str, hash_: str,
|
||||||
**kwargs: typing.Any) -> str:
|
**kwargs: typing.Any) -> str:
|
||||||
return url_for(
|
return url_for(
|
||||||
"avatar",
|
"main.avatar",
|
||||||
from_=base64.urlsafe_b64encode(
|
from_=base64.urlsafe_b64encode(
|
||||||
entity.encode("utf-8"),
|
entity.encode("utf-8"),
|
||||||
).decode("ascii").rstrip("="),
|
).decode("ascii").rstrip("="),
|
||||||
@@ -162,40 +32,41 @@ def proc() -> typing.Dict[str, typing.Any]:
|
|||||||
return {
|
return {
|
||||||
"url_for_avatar": url_for_avatar,
|
"url_for_avatar": url_for_avatar,
|
||||||
"text_to_css": colour.text_to_css,
|
"text_to_css": colour.text_to_css,
|
||||||
"lang": selected_locale(),
|
"lang": infra.selected_locale(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
app.template_filter("repr")(repr)
|
def create_app() -> quart.Quart:
|
||||||
app.template_filter("format_datetime")(flask_babel.format_datetime)
|
app = quart.Quart(__name__)
|
||||||
app.template_filter("format_date")(flask_babel.format_date)
|
app.config.setdefault("LANGUAGES", ["de", "en"])
|
||||||
app.template_filter("format_time")(flask_babel.format_time)
|
app.config.from_envvar("SNIKKET_WEB_CONFIG")
|
||||||
app.template_filter("format_timedelta")(flask_babel.format_timedelta)
|
app.context_processor(proc)
|
||||||
|
|
||||||
|
logging_config = app.config.get("LOGGING_CONFIG")
|
||||||
|
if logging_config is not None:
|
||||||
|
if isinstance(logging_config, dict):
|
||||||
|
logging.config.dictConfig(logging_config)
|
||||||
|
elif isinstance(logging_config, (bytes, str, pathlib.Path)):
|
||||||
|
import toml
|
||||||
|
with open(logging_config, "r") as f:
|
||||||
|
logging_config = toml.load(f)
|
||||||
|
logging.config.dictConfig(logging_config)
|
||||||
|
|
||||||
@app.template_filter("flatten")
|
else:
|
||||||
def flatten(a: typing.Iterable, levels: int = 1) -> typing.Iterable:
|
logging.basicConfig(level=logging.WARNING)
|
||||||
for i in range(levels):
|
if app.debug:
|
||||||
a = itertools.chain(*a)
|
logging.getLogger("snikket_web").setLevel(logging.DEBUG)
|
||||||
return a
|
|
||||||
|
|
||||||
|
infra.babel.init_app(app)
|
||||||
|
infra.client.init_app(app)
|
||||||
|
infra.init_templating(app)
|
||||||
|
|
||||||
from .user import user_bp # noqa:F401,E402
|
from .main import bp as main_bp
|
||||||
from .admin import bp as admin_bp # noqa:F401,E402
|
from .user import bp as user_bp
|
||||||
app.register_blueprint(user_bp)
|
from .admin import bp as admin_bp
|
||||||
app.register_blueprint(admin_bp)
|
|
||||||
|
|
||||||
logging_config = app.config.get("LOGGING_CONFIG")
|
app.register_blueprint(main_bp)
|
||||||
if logging_config is not None:
|
app.register_blueprint(user_bp, url_prefix="/user")
|
||||||
if isinstance(logging_config, dict):
|
app.register_blueprint(admin_bp, url_prefix="/admin")
|
||||||
logging.config.dictConfig(logging_config)
|
|
||||||
elif isinstance(logging_config, (bytes, str, pathlib.Path)):
|
|
||||||
import toml
|
|
||||||
with open(logging_config, "r") as f:
|
|
||||||
logging_config = toml.load(f)
|
|
||||||
logging.config.dictConfig(logging_config)
|
|
||||||
|
|
||||||
else:
|
return app
|
||||||
logging.basicConfig(level=logging.WARNING)
|
|
||||||
if app.debug:
|
|
||||||
logging.getLogger("snikket_web").setLevel(logging.DEBUG)
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import flask_wtf
|
|||||||
|
|
||||||
from flask_babel import lazy_gettext as _l
|
from flask_babel import lazy_gettext as _l
|
||||||
|
|
||||||
from snikket_web.prosodyclient import client
|
from .infra import client
|
||||||
|
|
||||||
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ async def users() -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class DeleteUserForm(flask_wtf.FlaskForm):
|
class DeleteUserForm(flask_wtf.FlaskForm): # type:ignore
|
||||||
action_delete = wtforms.SubmitField(
|
action_delete = wtforms.SubmitField(
|
||||||
_l("Delete user permanently")
|
_l("Delete user permanently")
|
||||||
)
|
)
|
||||||
@@ -64,7 +64,7 @@ async def delete_user(localpart: str) -> typing.Union[str, quart.Response]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class InvitesListForm(flask_wtf.FlaskForm):
|
class InvitesListForm(flask_wtf.FlaskForm): # type:ignore
|
||||||
action_revoke = wtforms.StringField()
|
action_revoke = wtforms.StringField()
|
||||||
|
|
||||||
action_create_invite = wtforms.SubmitField(
|
action_create_invite = wtforms.SubmitField(
|
||||||
@@ -99,7 +99,7 @@ async def invitations() -> typing.Union[str, quart.Response]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class InviteForm(flask_wtf.FlaskForm):
|
class InviteForm(flask_wtf.FlaskForm): # type:ignore
|
||||||
action_revoke = wtforms.SubmitField(
|
action_revoke = wtforms.SubmitField(
|
||||||
_l("Revoke")
|
_l("Revoke")
|
||||||
)
|
)
|
||||||
40
snikket_web/infra.py
Normal file
40
snikket_web/infra.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import itertools
|
||||||
|
import typing
|
||||||
|
|
||||||
|
import quart.flask_patch # noqa:F401
|
||||||
|
from quart import (
|
||||||
|
current_app,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
|
||||||
|
import flask_babel
|
||||||
|
|
||||||
|
from . import prosodyclient
|
||||||
|
|
||||||
|
|
||||||
|
client = prosodyclient.ProsodyClient()
|
||||||
|
client.default_login_redirect = "main.login"
|
||||||
|
|
||||||
|
babel = flask_babel.Babel()
|
||||||
|
|
||||||
|
|
||||||
|
@babel.localeselector # type:ignore
|
||||||
|
def selected_locale() -> str:
|
||||||
|
return request.accept_languages.best_match(
|
||||||
|
current_app.config['LANGUAGES']
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def flatten(a: typing.Iterable, levels: int = 1) -> typing.Iterable:
|
||||||
|
for i in range(levels):
|
||||||
|
a = itertools.chain(*a)
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
def init_templating(app: quart.Quart) -> None:
|
||||||
|
app.template_filter("repr")(repr)
|
||||||
|
app.template_filter("format_datetime")(flask_babel.format_datetime)
|
||||||
|
app.template_filter("format_date")(flask_babel.format_date)
|
||||||
|
app.template_filter("format_time")(flask_babel.format_time)
|
||||||
|
app.template_filter("format_timedelta")(flask_babel.format_timedelta)
|
||||||
|
app.template_filter("flatten")(flatten)
|
||||||
130
snikket_web/main.py
Normal file
130
snikket_web/main.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import typing
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import quart
|
||||||
|
import quart.flask_patch
|
||||||
|
from quart import (
|
||||||
|
current_app,
|
||||||
|
redirect,
|
||||||
|
url_for,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
Response,
|
||||||
|
)
|
||||||
|
|
||||||
|
import wtforms
|
||||||
|
|
||||||
|
import flask_wtf
|
||||||
|
|
||||||
|
from flask_babel import lazy_gettext as _l, _
|
||||||
|
|
||||||
|
from . import xmpputil, _version
|
||||||
|
from .infra import client
|
||||||
|
|
||||||
|
|
||||||
|
bp = quart.Blueprint("main", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginForm(flask_wtf.FlaskForm): # type:ignore
|
||||||
|
address = wtforms.TextField(
|
||||||
|
_l("Address"),
|
||||||
|
validators=[wtforms.validators.InputRequired()],
|
||||||
|
)
|
||||||
|
|
||||||
|
password = wtforms.PasswordField(
|
||||||
|
_l("Password"),
|
||||||
|
validators=[wtforms.validators.InputRequired()],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/login", methods=["GET", "POST"])
|
||||||
|
async def login() -> typing.Union[str, quart.Response]:
|
||||||
|
if client.has_session and (await client.test_session()):
|
||||||
|
return redirect(url_for('user.index'))
|
||||||
|
|
||||||
|
form = LoginForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
jid = form.address.data
|
||||||
|
localpart, domain, resource = xmpputil.split_jid(jid)
|
||||||
|
if not localpart:
|
||||||
|
localpart, domain = domain, current_app.config["SNIKKET_DOMAIN"]
|
||||||
|
jid = "{}@{}".format(localpart, domain)
|
||||||
|
password = form.password.data
|
||||||
|
try:
|
||||||
|
await client.login(jid, password)
|
||||||
|
except quart.exceptions.Unauthorized:
|
||||||
|
form.password.errors.append(
|
||||||
|
_("Invalid user name or password.")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return redirect(url_for('user.index'))
|
||||||
|
|
||||||
|
return await render_template("login.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/")
|
||||||
|
async def home() -> quart.Response:
|
||||||
|
if client.has_session:
|
||||||
|
return redirect(url_for('user.index'))
|
||||||
|
|
||||||
|
return redirect(url_for('.login'))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/meta/about.html")
|
||||||
|
async def about() -> str:
|
||||||
|
return await render_template("about.html", version=_version.version)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/meta/demo.html")
|
||||||
|
async def demo() -> str:
|
||||||
|
return await render_template("demo.html")
|
||||||
|
|
||||||
|
|
||||||
|
def repad(s: str) -> str:
|
||||||
|
return s + "=" * (4 - len(s) % 4)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/avatar/<from_>/<code>")
|
||||||
|
async def avatar(from_: str, code: str) -> quart.Response:
|
||||||
|
try:
|
||||||
|
etag = request.headers["if-none-match"]
|
||||||
|
except KeyError:
|
||||||
|
etag = None
|
||||||
|
|
||||||
|
address = base64.urlsafe_b64decode(repad(from_)).decode("utf-8")
|
||||||
|
info = await client.get_avatar(address, metadata_only=True)
|
||||||
|
bin_hash = binascii.a2b_hex(info["sha1"])
|
||||||
|
new_etag = base64.urlsafe_b64encode(bin_hash).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
cache_ttl = timedelta(seconds=current_app.config.get(
|
||||||
|
"AVATAR_CACHE_TTL",
|
||||||
|
300,
|
||||||
|
))
|
||||||
|
|
||||||
|
response = Response("", mimetype=info["type"])
|
||||||
|
response.headers["etag"] = new_etag
|
||||||
|
# XXX: It seems to me that quart expects localtime(?!) in this field...
|
||||||
|
response.expires = datetime.now() + cache_ttl
|
||||||
|
response.headers["Content-Security-Policy"] = \
|
||||||
|
"frame-ancestors 'none'; default-src 'none'; style-src 'unsafe-inline'"
|
||||||
|
|
||||||
|
if etag is not None and new_etag == etag:
|
||||||
|
response.status_code = 304
|
||||||
|
return response
|
||||||
|
|
||||||
|
data = await client.get_avatar_data(address, info["sha1"])
|
||||||
|
if data is None:
|
||||||
|
response.status_code = 404
|
||||||
|
return response
|
||||||
|
|
||||||
|
response.status_code = 200
|
||||||
|
|
||||||
|
if request.method == "HEAD":
|
||||||
|
response.content_length = len(data)
|
||||||
|
return response
|
||||||
|
|
||||||
|
response.set_data(data)
|
||||||
|
return response
|
||||||
@@ -320,13 +320,13 @@ class ProsodyClient:
|
|||||||
**kwargs: typing.Any,
|
**kwargs: typing.Any,
|
||||||
) -> typing.Union[T, quart.Response]:
|
) -> typing.Union[T, quart.Response]:
|
||||||
if not self.has_session or not (await self.test_session()):
|
if not self.has_session or not (await self.test_session()):
|
||||||
nonlocal redirect_to
|
redirect_to_value = redirect_to
|
||||||
if redirect_to is not False:
|
if redirect_to_value is not False:
|
||||||
redirect_to = \
|
redirect_to_value = \
|
||||||
redirect_to or self._default_login_redirect
|
redirect_to_value or self._default_login_redirect
|
||||||
if not redirect_to:
|
if not redirect_to_value:
|
||||||
raise abort(401, "Not Authorized")
|
raise abort(401, "Not Authorized")
|
||||||
return redirect(url_for(redirect_to))
|
return redirect(url_for(redirect_to_value))
|
||||||
|
|
||||||
return await f(*args, **kwargs)
|
return await f(*args, **kwargs)
|
||||||
return wrapped
|
return wrapped
|
||||||
@@ -664,6 +664,3 @@ class ProsodyClient:
|
|||||||
return False
|
return False
|
||||||
scopes = http_session[self.SESSION_CACHED_SCOPE].split()
|
scopes = http_session[self.SESSION_CACHED_SCOPE].split()
|
||||||
return SCOPE_ADMIN in scopes
|
return SCOPE_ADMIN in scopes
|
||||||
|
|
||||||
|
|
||||||
client = ProsodyClient()
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Snikket Web Portal ({{ version }})</pre>
|
|||||||
AVATAR_CACHE_TTL = {{ config.get("AVATAR_CACHE_TTL") | repr }}
|
AVATAR_CACHE_TTL = {{ config.get("AVATAR_CACHE_TTL") | repr }}
|
||||||
SECRET_KEY = <hidden>
|
SECRET_KEY = <hidden>
|
||||||
PROSODY_ENDPOINT = <hidden></pre> #}
|
PROSODY_ENDPOINT = <hidden></pre> #}
|
||||||
<p><a href="{{ url_for('home') }}" class="button primary">Back to main page</a></pa>
|
<p><a href="{{ url_for('.home') }}" class="button primary">Back to main page</a></pa>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -17,6 +17,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<main>{% block content %}{% endblock %}</main>
|
<main>{% block content %}{% endblock %}</main>
|
||||||
<footer>
|
<footer>
|
||||||
<ul><li>{% trans about_url=url_for('about') %}A <a href="{{ about_url }}">Snikket</a> server{% endtrans %}</li></ul>
|
<ul><li>{% trans about_url=url_for('main.about') %}A <a href="{{ about_url }}">Snikket</a> server{% endtrans %}</li></ul>
|
||||||
</footer>
|
</footer>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<main><div class="form layout-expanded">
|
<main><div class="form layout-expanded">
|
||||||
<h1 class="form-title">{{ config["SNIKKET_DOMAIN"] }}</h1>
|
<h1 class="form-title">{{ config["SNIKKET_DOMAIN"] }}</h1>
|
||||||
<p class="form-desc">{{ _("Enter your Snikket address and password to manage your account.") }}</p>
|
<p class="form-desc">{{ _("Enter your Snikket address and password to manage your account.") }}</p>
|
||||||
<form method="POST" action="{{ url_for('login') }}" name="login">
|
<form method="POST" action="{{ url_for('.login') }}" name="login">
|
||||||
{{ form.csrf_token }}
|
{{ form.csrf_token }}
|
||||||
{% if form.errors %}
|
{% if form.errors %}
|
||||||
{% call box("alert", _("Login failed")) %}
|
{% call box("alert", _("Login failed")) %}
|
||||||
@@ -33,6 +33,6 @@
|
|||||||
</from>
|
</from>
|
||||||
</div></main>
|
</div></main>
|
||||||
<footer>
|
<footer>
|
||||||
<ul><li>{% trans about_url=url_for('about') %}A <a href="{{ about_url }}">Snikket</a> server{% endtrans %}</li></ul>
|
<ul><li>{% trans about_url=url_for('.about') %}A <a href="{{ about_url }}">Snikket</a> server{% endtrans %}</li></ul>
|
||||||
</footer>
|
</footer>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
<h1>{% trans %}Welcome!{% endtrans %}</h1>
|
<h1>{% trans %}Welcome!{% endtrans %}</h1>
|
||||||
<p>{% trans user_name=user_info.display_name %}Welcome home, {{ user_name }}.{% endtrans %}</p>
|
<p>{% trans user_name=user_info.display_name %}Welcome home, {{ user_name }}.{% endtrans %}</p>
|
||||||
<div class="welcome-cards">
|
<div class="welcome-cards">
|
||||||
<a class="card" href="{{ url_for('user.profile') }}">
|
<a class="card" href="{{ url_for('.profile') }}">
|
||||||
<h2>{% trans %}Update profile{% endtrans %}</h2>
|
<h2>{% trans %}Update profile{% endtrans %}</h2>
|
||||||
<p>{% trans %}Change display name, set avatar and configure visibility of your personal data to others.{% endtrans %}</p>
|
<p>{% trans %}Change display name, set avatar and configure visibility of your personal data to others.{% endtrans %}</p>
|
||||||
</a>
|
</a>
|
||||||
<a class="card" href="{{ url_for('user.change_pw') }}">
|
<a class="card" href="{{ url_for('.change_pw') }}">
|
||||||
<h2>{% trans %}Change password{% endtrans %}</h2>
|
<h2>{% trans %}Change password{% endtrans %}</h2>
|
||||||
</a>
|
</a>
|
||||||
{% if user_info.is_admin %}
|
{% if user_info.is_admin %}
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<p>{% trans %}Manage users and invitations of this Snikket instance.{% endtrans %}</p>
|
<p>{% trans %}Manage users and invitations of this Snikket instance.{% endtrans %}</p>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="card" href="{{ url_for('user.logout') }}">
|
<a class="card" href="{{ url_for('.logout') }}">
|
||||||
<h2>{% trans %}Log out{% endtrans %}</h2>
|
<h2>{% trans %}Log out{% endtrans %}</h2>
|
||||||
<p>{% trans %}Exit the Snikket Web Portal, without logging out your other devices.{% endtrans %}</p>
|
<p>{% trans %}Exit the Snikket Web Portal, without logging out your other devices.{% endtrans %}</p>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
<p>{% trans %}After changing your password, you will have to enter the new password on all of your devices.{% endtrans %}</p>
|
<p>{% trans %}After changing your password, you will have to enter the new password on all of your devices.{% endtrans %}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="f-bbox">
|
<div class="f-bbox">
|
||||||
<a href="{{ url_for('user.index') }}" class="button secondary">{% trans %}Back{% endtrans %}</a>
|
<a href="{{ url_for('.index') }}" class="button secondary">{% trans %}Back{% endtrans %}</a>
|
||||||
<button type="submit" class="primary">{% trans %}Change password{% endtrans %}</button>
|
<button type="submit" class="primary">{% trans %}Change password{% endtrans %}</button>
|
||||||
</div>
|
</div>
|
||||||
</form></div>
|
</form></div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: SnikketWeb 0.1.0\n"
|
"Project-Id-Version: SnikketWeb 0.1.0\n"
|
||||||
"Report-Msgid-Bugs-To: jonas@zombofant.net\n"
|
"Report-Msgid-Bugs-To: jonas@zombofant.net\n"
|
||||||
"POT-Creation-Date: 2021-01-17 20:09+0100\n"
|
"POT-Creation-Date: 2021-01-17 20:11+0100\n"
|
||||||
"PO-Revision-Date: 2020-03-07 16:32+0100\n"
|
"PO-Revision-Date: 2020-03-07 16:32+0100\n"
|
||||||
"Last-Translator: Jonas Schäfer <jonas@zombofant.net>\n"
|
"Last-Translator: Jonas Schäfer <jonas@zombofant.net>\n"
|
||||||
"Language: de\n"
|
"Language: de\n"
|
||||||
@@ -18,30 +18,60 @@ msgstr ""
|
|||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Generated-By: Babel 2.9.0\n"
|
"Generated-By: Babel 2.9.0\n"
|
||||||
|
|
||||||
#: snikket_web/__init__.py:40
|
#: snikket_web/admin.py:44
|
||||||
msgid "Address"
|
|
||||||
msgstr "Adresse"
|
|
||||||
|
|
||||||
#: snikket_web/__init__.py:45
|
|
||||||
msgid "Password"
|
|
||||||
msgstr "Passwort"
|
|
||||||
|
|
||||||
#: snikket_web/__init__.py:74
|
|
||||||
msgid "Invalid user name or password."
|
|
||||||
msgstr "Benutzername oder Passwort falsch."
|
|
||||||
|
|
||||||
#: snikket_web/admin/__init__.py:44
|
|
||||||
msgid "Delete user permanently"
|
msgid "Delete user permanently"
|
||||||
msgstr "Benutzer endgültig löschen"
|
msgstr "Benutzer endgültig löschen"
|
||||||
|
|
||||||
#: snikket_web/admin/__init__.py:71
|
#: snikket_web/admin.py:71
|
||||||
msgid "New invitation link"
|
msgid "New invitation link"
|
||||||
msgstr "Neuer Einladungslink"
|
msgstr "Neuer Einladungslink"
|
||||||
|
|
||||||
#: snikket_web/admin/__init__.py:104
|
#: snikket_web/admin.py:104
|
||||||
msgid "Revoke"
|
msgid "Revoke"
|
||||||
msgstr "Löschen"
|
msgstr "Löschen"
|
||||||
|
|
||||||
|
#: snikket_web/main.py:33
|
||||||
|
msgid "Address"
|
||||||
|
msgstr "Adresse"
|
||||||
|
|
||||||
|
#: snikket_web/main.py:38
|
||||||
|
msgid "Password"
|
||||||
|
msgstr "Passwort"
|
||||||
|
|
||||||
|
#: snikket_web/main.py:60
|
||||||
|
msgid "Invalid user name or password."
|
||||||
|
msgstr "Benutzername oder Passwort falsch."
|
||||||
|
|
||||||
|
#: snikket_web/user.py:25
|
||||||
|
msgid "Current password"
|
||||||
|
msgstr "Aktuelles Passwort"
|
||||||
|
|
||||||
|
#: snikket_web/user.py:30
|
||||||
|
msgid "New password"
|
||||||
|
msgstr "Neues Passwort"
|
||||||
|
|
||||||
|
#: snikket_web/user.py:35
|
||||||
|
msgid "Confirm new password"
|
||||||
|
msgstr "Neues Passwort (Bestätigung)"
|
||||||
|
|
||||||
|
#: snikket_web/user.py:39
|
||||||
|
msgid "The new passwords must match."
|
||||||
|
msgstr "Die neuen Passwörter müssen übereinstimmen."
|
||||||
|
|
||||||
|
#: snikket_web/templates/admin_delete_user.html:12
|
||||||
|
#: snikket_web/templates/admin_delete_user.html:16
|
||||||
|
#: snikket_web/templates/admin_users.html:15 snikket_web/user.py:50
|
||||||
|
msgid "Display name"
|
||||||
|
msgstr "Anzeigename"
|
||||||
|
|
||||||
|
#: snikket_web/user.py:54
|
||||||
|
msgid "Avatar"
|
||||||
|
msgstr "Bild"
|
||||||
|
|
||||||
|
#: snikket_web/user.py:78
|
||||||
|
msgid "Incorrect password"
|
||||||
|
msgstr "Ungültiges Passwort"
|
||||||
|
|
||||||
#: snikket_web/templates/admin_delete_user.html:4
|
#: snikket_web/templates/admin_delete_user.html:4
|
||||||
#: snikket_web/templates/admin_users.html:29
|
#: snikket_web/templates/admin_users.html:29
|
||||||
#, python-format
|
#, python-format
|
||||||
@@ -62,12 +92,6 @@ msgstr "Bist du sicher dass du den folgenden Benutzer löschen willst?"
|
|||||||
msgid "Login name"
|
msgid "Login name"
|
||||||
msgstr "Anmeldename"
|
msgstr "Anmeldename"
|
||||||
|
|
||||||
#: snikket_web/templates/admin_delete_user.html:12
|
|
||||||
#: snikket_web/templates/admin_delete_user.html:16
|
|
||||||
#: snikket_web/templates/admin_users.html:15 snikket_web/user/__init__.py:48
|
|
||||||
msgid "Display name"
|
|
||||||
msgstr "Anzeigename"
|
|
||||||
|
|
||||||
#: snikket_web/templates/admin_delete_user.html:14
|
#: snikket_web/templates/admin_delete_user.html:14
|
||||||
#: snikket_web/templates/admin_users.html:16
|
#: snikket_web/templates/admin_users.html:16
|
||||||
msgid "Email address"
|
msgid "Email address"
|
||||||
@@ -287,30 +311,6 @@ msgstr "Profil"
|
|||||||
msgid "Apply"
|
msgid "Apply"
|
||||||
msgstr "Übernehmen"
|
msgstr "Übernehmen"
|
||||||
|
|
||||||
#: snikket_web/user/__init__.py:23
|
|
||||||
msgid "Current password"
|
|
||||||
msgstr "Aktuelles Passwort"
|
|
||||||
|
|
||||||
#: snikket_web/user/__init__.py:28
|
|
||||||
msgid "New password"
|
|
||||||
msgstr "Neues Passwort"
|
|
||||||
|
|
||||||
#: snikket_web/user/__init__.py:33
|
|
||||||
msgid "Confirm new password"
|
|
||||||
msgstr "Neues Passwort (Bestätigung)"
|
|
||||||
|
|
||||||
#: snikket_web/user/__init__.py:37
|
|
||||||
msgid "The new passwords must match."
|
|
||||||
msgstr "Die neuen Passwörter müssen übereinstimmen."
|
|
||||||
|
|
||||||
#: snikket_web/user/__init__.py:52
|
|
||||||
msgid "Avatar"
|
|
||||||
msgstr "Bild"
|
|
||||||
|
|
||||||
#: snikket_web/user/__init__.py:76
|
|
||||||
msgid "Incorrect password"
|
|
||||||
msgstr "Ungültiges Passwort"
|
|
||||||
|
|
||||||
#~ msgid "none"
|
#~ msgid "none"
|
||||||
#~ msgstr "keiner"
|
#~ msgstr "keiner"
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2021-01-17 20:09+0100\n"
|
"POT-Creation-Date: 2021-01-17 20:11+0100\n"
|
||||||
"PO-Revision-Date: 2020-03-07 16:50+0100\n"
|
"PO-Revision-Date: 2020-03-07 16:50+0100\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: en\n"
|
"Language: en\n"
|
||||||
@@ -18,30 +18,60 @@ msgstr ""
|
|||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Generated-By: Babel 2.9.0\n"
|
"Generated-By: Babel 2.9.0\n"
|
||||||
|
|
||||||
#: snikket_web/__init__.py:40
|
#: snikket_web/admin.py:44
|
||||||
|
msgid "Delete user permanently"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: snikket_web/admin.py:71
|
||||||
|
msgid "New invitation link"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: snikket_web/admin.py:104
|
||||||
|
msgid "Revoke"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: snikket_web/main.py:33
|
||||||
msgid "Address"
|
msgid "Address"
|
||||||
msgstr "Address"
|
msgstr "Address"
|
||||||
|
|
||||||
#: snikket_web/__init__.py:45
|
#: snikket_web/main.py:38
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr "Password"
|
msgstr "Password"
|
||||||
|
|
||||||
#: snikket_web/__init__.py:74
|
#: snikket_web/main.py:60
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
msgid "Invalid user name or password."
|
msgid "Invalid user name or password."
|
||||||
msgstr "Confirm new password"
|
msgstr "Confirm new password"
|
||||||
|
|
||||||
#: snikket_web/admin/__init__.py:44
|
#: snikket_web/user.py:25
|
||||||
msgid "Delete user permanently"
|
msgid "Current password"
|
||||||
msgstr ""
|
msgstr "Current password"
|
||||||
|
|
||||||
#: snikket_web/admin/__init__.py:71
|
#: snikket_web/user.py:30
|
||||||
msgid "New invitation link"
|
msgid "New password"
|
||||||
msgstr ""
|
msgstr "New password"
|
||||||
|
|
||||||
#: snikket_web/admin/__init__.py:104
|
#: snikket_web/user.py:35
|
||||||
msgid "Revoke"
|
msgid "Confirm new password"
|
||||||
msgstr ""
|
msgstr "Confirm new password"
|
||||||
|
|
||||||
|
#: snikket_web/user.py:39
|
||||||
|
msgid "The new passwords must match."
|
||||||
|
msgstr "The new passwords must match."
|
||||||
|
|
||||||
|
#: snikket_web/templates/admin_delete_user.html:12
|
||||||
|
#: snikket_web/templates/admin_delete_user.html:16
|
||||||
|
#: snikket_web/templates/admin_users.html:15 snikket_web/user.py:50
|
||||||
|
msgid "Display name"
|
||||||
|
msgstr "Display name"
|
||||||
|
|
||||||
|
#: snikket_web/user.py:54
|
||||||
|
msgid "Avatar"
|
||||||
|
msgstr "Avatar"
|
||||||
|
|
||||||
|
#: snikket_web/user.py:78
|
||||||
|
msgid "Incorrect password"
|
||||||
|
msgstr "Incorrect password"
|
||||||
|
|
||||||
#: snikket_web/templates/admin_delete_user.html:4
|
#: snikket_web/templates/admin_delete_user.html:4
|
||||||
#: snikket_web/templates/admin_users.html:29
|
#: snikket_web/templates/admin_users.html:29
|
||||||
@@ -63,12 +93,6 @@ msgstr ""
|
|||||||
msgid "Login name"
|
msgid "Login name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: snikket_web/templates/admin_delete_user.html:12
|
|
||||||
#: snikket_web/templates/admin_delete_user.html:16
|
|
||||||
#: snikket_web/templates/admin_users.html:15 snikket_web/user/__init__.py:48
|
|
||||||
msgid "Display name"
|
|
||||||
msgstr "Display name"
|
|
||||||
|
|
||||||
#: snikket_web/templates/admin_delete_user.html:14
|
#: snikket_web/templates/admin_delete_user.html:14
|
||||||
#: snikket_web/templates/admin_users.html:16
|
#: snikket_web/templates/admin_users.html:16
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
@@ -281,30 +305,6 @@ msgstr "Profile"
|
|||||||
msgid "Apply"
|
msgid "Apply"
|
||||||
msgstr "Apply"
|
msgstr "Apply"
|
||||||
|
|
||||||
#: snikket_web/user/__init__.py:23
|
|
||||||
msgid "Current password"
|
|
||||||
msgstr "Current password"
|
|
||||||
|
|
||||||
#: snikket_web/user/__init__.py:28
|
|
||||||
msgid "New password"
|
|
||||||
msgstr "New password"
|
|
||||||
|
|
||||||
#: snikket_web/user/__init__.py:33
|
|
||||||
msgid "Confirm new password"
|
|
||||||
msgstr "Confirm new password"
|
|
||||||
|
|
||||||
#: snikket_web/user/__init__.py:37
|
|
||||||
msgid "The new passwords must match."
|
|
||||||
msgstr "The new passwords must match."
|
|
||||||
|
|
||||||
#: snikket_web/user/__init__.py:52
|
|
||||||
msgid "Avatar"
|
|
||||||
msgstr "Avatar"
|
|
||||||
|
|
||||||
#: snikket_web/user/__init__.py:76
|
|
||||||
msgid "Incorrect password"
|
|
||||||
msgstr "Incorrect password"
|
|
||||||
|
|
||||||
#~ msgid "none"
|
#~ msgid "none"
|
||||||
#~ msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
import typing
|
import typing
|
||||||
|
|
||||||
import quart.flask_patch
|
import quart.flask_patch
|
||||||
|
|
||||||
from quart import Blueprint, render_template, request, redirect, url_for
|
from quart import Blueprint, render_template, request, redirect, url_for
|
||||||
import quart.exceptions
|
import quart.exceptions
|
||||||
from flask_wtf import FlaskForm
|
|
||||||
from flask_babel import lazy_gettext as _l, _
|
|
||||||
import wtforms
|
import wtforms
|
||||||
|
|
||||||
from snikket_web.prosodyclient import client
|
import flask_wtf
|
||||||
|
|
||||||
user_bp = Blueprint('user', __name__, url_prefix="/user")
|
from flask_babel import lazy_gettext as _l, _
|
||||||
|
|
||||||
|
from .infra import client
|
||||||
|
|
||||||
|
bp = Blueprint('user', __name__)
|
||||||
|
|
||||||
|
|
||||||
@user_bp.context_processor
|
@bp.context_processor
|
||||||
async def proc() -> typing.Mapping[str, typing.Any]:
|
async def proc() -> typing.Mapping[str, typing.Any]:
|
||||||
return {"user_info": await client.get_user_info()}
|
return {"user_info": await client.get_user_info()}
|
||||||
|
|
||||||
|
|
||||||
class ChangePasswordForm(FlaskForm):
|
class ChangePasswordForm(flask_wtf.FlaskForm): # type:ignore
|
||||||
current_password = wtforms.PasswordField(
|
current_password = wtforms.PasswordField(
|
||||||
_l("Current password"),
|
_l("Current password"),
|
||||||
validators=[wtforms.validators.InputRequired()]
|
validators=[wtforms.validators.InputRequired()]
|
||||||
@@ -39,11 +41,11 @@ class ChangePasswordForm(FlaskForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class LogoutForm(FlaskForm):
|
class LogoutForm(flask_wtf.FlaskForm): # type:ignore
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ProfileForm(FlaskForm):
|
class ProfileForm(flask_wtf.FlaskForm): # type:ignore
|
||||||
nickname = wtforms.TextField(
|
nickname = wtforms.TextField(
|
||||||
_l("Display name"),
|
_l("Display name"),
|
||||||
)
|
)
|
||||||
@@ -53,14 +55,14 @@ class ProfileForm(FlaskForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@user_bp.route("/")
|
@bp.route("/")
|
||||||
@client.require_session()
|
@client.require_session()
|
||||||
async def index() -> str:
|
async def index() -> str:
|
||||||
user_info = await client.get_user_info()
|
user_info = await client.get_user_info()
|
||||||
return await render_template("user_home.html", user_info=user_info)
|
return await render_template("user_home.html", user_info=user_info)
|
||||||
|
|
||||||
|
|
||||||
@user_bp.route('/passwd', methods=["GET", "POST"])
|
@bp.route('/passwd', methods=["GET", "POST"])
|
||||||
@client.require_session()
|
@client.require_session()
|
||||||
async def change_pw() -> typing.Union[str, quart.Response]:
|
async def change_pw() -> typing.Union[str, quart.Response]:
|
||||||
form = ChangePasswordForm()
|
form = ChangePasswordForm()
|
||||||
@@ -81,7 +83,7 @@ async def change_pw() -> typing.Union[str, quart.Response]:
|
|||||||
return await render_template("user_passwd.html", form=form)
|
return await render_template("user_passwd.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
@user_bp.route("/profile", methods=["GET", "POST"])
|
@bp.route("/profile", methods=["GET", "POST"])
|
||||||
@client.require_session()
|
@client.require_session()
|
||||||
async def profile() -> typing.Union[str, quart.Response]:
|
async def profile() -> typing.Union[str, quart.Response]:
|
||||||
form = ProfileForm()
|
form = ProfileForm()
|
||||||
@@ -106,12 +108,12 @@ async def profile() -> typing.Union[str, quart.Response]:
|
|||||||
return await render_template("user_profile.html", form=form)
|
return await render_template("user_profile.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
@user_bp.route("/logout", methods=["GET", "POST"])
|
@bp.route("/logout", methods=["GET", "POST"])
|
||||||
@client.require_session()
|
@client.require_session()
|
||||||
async def logout() -> typing.Union[quart.Response, str]:
|
async def logout() -> typing.Union[quart.Response, str]:
|
||||||
form = LogoutForm()
|
form = LogoutForm()
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
await client.logout()
|
await client.logout()
|
||||||
return redirect(url_for("home"))
|
return redirect(url_for("main.home"))
|
||||||
|
|
||||||
return await render_template("user_logout.html", form=form)
|
return await render_template("user_logout.html", form=form)
|
||||||
Reference in New Issue
Block a user