Skip to content

Commit

Permalink
Improve support for 2FA and unified signin method localizations. (#889)
Browse files Browse the repository at this point in the history
Improve on recent PR that adds support for 2FA method translations - it didn't actually call a localize_callback to translate the data.

Add tests to verify actual xlation is occurring.

Remove support for Flask-BabelEx.

Use Babel.format_lists to construct the list of existing setup unified signup methods - moving this from the template to the view.

Fix view_scaffold localization configuration.
  • Loading branch information
jwag956 authored Dec 19, 2023
1 parent 3018dde commit 9cb959a
Show file tree
Hide file tree
Showing 36 changed files with 1,621 additions and 1,228 deletions.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ repos:
- id: pyupgrade
args: [--py38-plus]
- repo: https://github.com/psf/black
rev: 23.11.0
rev: 23.12.0
hooks:
- id: black
- repo: https://github.com/pycqa/flake8
Expand Down
4 changes: 4 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ Fixes
- (:issue:`875`) user_datastore.create_user has side effects on mutable inputs (NoRePercussions)
- (:pr:`878`) The long deprecated _unauthorized_callback/handler has been removed.
- (:pr:`881`) No longer rely on Flask-Login.unauthorized callback. See below for implications.
- (:pr:`855`) Improve translations for two-factor method selection (gissimo)
- (:pr:`866`) Improve German translations (sr-verde)
- (:pr:`xxx`) Improve method translations for unified signin and two factor. Remove support for Flask-Babelex.

Notes
++++++
Expand Down Expand Up @@ -60,6 +63,7 @@ Backwards Compatibility Concerns

- Flask-Security no longer configures anything related to Flask-Login's `fresh_login` logic.
This shouldn't be used - instead use Flask-Security's :meth:`flask_security.auth_required` decorator.
- Support for Flask-Babelex has been removed. Please convert to Flask-Babel.


Version 5.3.2
Expand Down
6 changes: 1 addition & 5 deletions docs/customizing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,7 @@ appropriate input attributes can be set)::
Localization
------------
All messages, form labels, and form strings are localizable. Flask-Security uses
`Flask-Babel <https://pypi.org/project/Flask-Babel/>`_ or
`Flask-BabelEx <https://pythonhosted.org/Flask-BabelEx/>`_ to manage its messages.
`Flask-Babel <https://pypi.org/project/Flask-Babel/>`_ to manage its messages.

.. tip::
Be sure to explicitly initialize your babel extension::
Expand Down Expand Up @@ -350,9 +349,6 @@ Finally add your translations directory to your configuration::

app.config["SECURITY_I18N_DIRNAME"] = ["builtin", "translations"]

.. note::
This only works when using Flask-Babel since Flask-BabelEx doesn't support a list of translation directories.

.. _emails_topic:

Emails
Expand Down
3 changes: 3 additions & 0 deletions docs/models.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
Models
======

Basics
------

Flask-Security assumes you'll be using libraries such as SQLAlchemy,
MongoEngine, Peewee or PonyORM to define a `User`
and `Role` data model. The fields on your models must follow a particular convention
Expand Down
167 changes: 80 additions & 87 deletions flask_security/babel.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@
:license: MIT, see LICENSE for more details.
As of Flask-Babel 2.0.0 - it supports the Flask-BabelEx Domain extension - and it
is maintained. (Flask-BabelEx is no longer maintained). So we start with that,
then fall back to Flask-BabelEx, then fall back to a Null Domain
(just as Flask-Admin).
is maintained. If that isn't installed fall back to a Null Domain
"""

# flake8: noqa: F811
Expand All @@ -20,8 +18,6 @@
from contextlib import ExitStack
from importlib_resources import files, as_file

import typing as t

from flask import current_app
from .utils import config_value as cv

Expand All @@ -31,91 +27,88 @@ def has_babel_ext():
return current_app and "babel" in current_app.extensions


_domain_cls = None


try:
from flask_babel import Domain
import babel.support

_domain_cls = Domain
_dir_keyword = "translation_directories"
except ImportError: # pragma: no cover
try:
from flask_babelex import Domain
import babel.support

_domain_cls = Domain
_dir_keyword = "dirname"
except ImportError:
# Fake up just enough
class FsDomain:
def __init__(self, app):
pass

@staticmethod
def gettext(string, **variables):
from flask_babel import Domain, get_locale
from babel.support import LazyProxy
from babel.lists import format_list

class FsDomain(Domain):
def __init__(self, app):
# By default, we use our packaged translations. However, we have to allow
# for app to add translation directories or completely override ours.
# Grabbing the packaged translations is a bit complex - so we use
# the keyword 'builtin' to mean ours.
cfdir = cv("I18N_DIRNAME", app=app)
if cfdir == "builtin" or (
isinstance(cfdir, Iterable) and "builtin" in cfdir
):
fm = ExitStack()
atexit.register(fm.close)
ref = files("flask_security") / "translations"
path = fm.enter_context(as_file(ref))
if cfdir == "builtin":
dirs = [str(path)]
else:
dirs = [d if d != "builtin" else str(path) for d in cfdir]
else:
dirs = cfdir
super().__init__(
**{
"domain": cv("I18N_DOMAIN", app=app),
"translation_directories": dirs,
}
)

def gettext(self, string, **variables):
if not has_babel_ext():
return string if not variables else string % variables
return super().gettext(string, **variables)

@staticmethod
def ngettext(singular, plural, num, **variables):
def ngettext(self, singular, plural, num, **variables): # pragma: no cover
if not has_babel_ext():
variables.setdefault("num", num)
return (singular if num == 1 else plural) % variables
return super().ngettext(singular, plural, num, **variables)

def is_lazy_string(obj):
return False

def make_lazy_string(__func, msg):
return msg


if not t.TYPE_CHECKING:
# mypy doesn't understand all this
if _domain_cls:
from babel.support import LazyProxy

class FsDomain(_domain_cls):
def __init__(self, app):
# By default, we use our packaged translations. However, we have to allow
# for app to add translation directories or completely override ours.
# Grabbing the packaged translations is a bit complex - so we use
# the keyword 'builtin' to mean ours.
cfdir = cv("I18N_DIRNAME", app=app)
if cfdir == "builtin" or (
isinstance(cfdir, Iterable) and "builtin" in cfdir
):
fm = ExitStack()
atexit.register(fm.close)
ref = files("flask_security") / "translations"
path = fm.enter_context(as_file(ref))
if cfdir == "builtin":
dirs = [str(path)]
else:
dirs = [d if d != "builtin" else str(path) for d in cfdir]
else:
dirs = cfdir
super().__init__(
**{
"domain": cv("I18N_DOMAIN", app=app),
_dir_keyword: dirs,
}
)

def gettext(self, string, **variables):
if not has_babel_ext():
return string if not variables else string % variables
return super().gettext(string, **variables)

def ngettext(self, singular, plural, num, **variables): # pragma: no cover
if not has_babel_ext():
variables.setdefault("num", num)
return (singular if num == 1 else plural) % variables
return super().ngettext(singular, plural, num, **variables)

def is_lazy_string(obj):
"""Checks if the given object is a lazy string."""
return isinstance(obj, LazyProxy)

def make_lazy_string(__func, msg):
"""Creates a lazy string by invoking func with args."""
return LazyProxy(__func, msg, enable_cache=False)
@staticmethod
def format_list(lst, **kwargs):
# This is a Babel method
if not has_babel_ext():
return ", ".join(lst)
ll = get_locale()
return format_list(lst, locale=get_locale(), **kwargs)

def is_lazy_string(obj):
"""Checks if the given object is a lazy string."""
return isinstance(obj, LazyProxy)

def make_lazy_string(__func, msg):
"""Creates a lazy string by invoking func with args."""
return LazyProxy(__func, msg, enable_cache=False)

except ImportError: # pragma: no cover
# Fake up just enough
class FsDomain: # type: ignore[no-redef]
def __init__(self, app):
pass

@staticmethod
def gettext(string, **variables):
return string if not variables else string % variables

@staticmethod
def ngettext(singular, plural, num, **variables):
variables.setdefault("num", num)
return (singular if num == 1 else plural) % variables

@staticmethod
def format_list(lst, **kwargs):
# This is a Babel method
if not has_babel_ext():
return ", ".join(lst)

def is_lazy_string(obj):
return False

def make_lazy_string(__func, msg):
return msg
4 changes: 4 additions & 0 deletions flask_security/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,10 @@
_("You successfully disabled two factor authorization."),
"success",
),
"US_CURRENT_METHODS": (
_("Currently active sign in options: %(method_list)s."),
"info",
),
"US_METHOD_NOT_AVAILABLE": (_("Requested method is not valid"), "error"),
"US_SETUP_EXPIRED": (
_("Setup must be completed within %(within)s. Please start over."),
Expand Down
12 changes: 7 additions & 5 deletions flask_security/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,15 @@
"sms_method": _("Set up using SMS"),
}

_tf_methods_xlate = {
# translated methods for two-factor and us-signin. keyed by form 'choices'
_setup_methods_xlate = {
"google_authenticator": _("Google Authenticator"),
"authenticator": _("Authenticator app"),
"email": _("Email"),
"mail": _("Email"),
"authenticator": _("authenticator"),
"email": _("email"),
"mail": _("email"),
"sms": _("SMS"),
None: _("None"),
"password": _("password"),
None: _("none"),
}


Expand Down
11 changes: 3 additions & 8 deletions flask_security/templates/security/us_setup.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
On GET:
available_methods: Value of SECURITY_US_ENABLED_METHODS
active_methods: Which methods user has already set up
current_methods_msg: a translated string of already set up methods
setup_methods: Which methods require a setup (e.g. password doesn't require any setup)

On successful POST:
available_methods: Value of SECURITY_US_ENABLED_METHODS
active_methods: Which methods user has already set up
current_methods_msg: a translated string of already set up methods
setup_methods: Which methods require a setup (e.g. password doesn't require any setup)
chosen_method: which identity method was chosen (e.g. sms, authenticator)
code_sent: Was a code sent?
Expand All @@ -31,14 +33,7 @@ <h1>{{ _fsdomain("Setup Unified Sign In") }}</h1>
{{ render_form_errors(us_setup_form) }}
{% if setup_methods %}
<div class="fs-div">
{{ _fsdomain("Currently active sign in options:") }}
<em>
{% if active_methods %}
{{ ", ".join(active_methods) }}
{% else %}
None.
{% endif %}
</em>
{{ current_methods_msg }}
</div>
<h3>{{ us_setup_form.chosen_method.label }}</h3>
<div class="fs-div">
Expand Down
Loading

0 comments on commit 9cb959a

Please sign in to comment.