diff --git a/changelog.d/9275.feature b/changelog.d/9275.feature
new file mode 100644
index 0000000000..c21b197ca1
--- /dev/null
+++ b/changelog.d/9275.feature
@@ -0,0 +1 @@
+Improve the user experience of setting up an account via single-sign on.
diff --git a/docs/sample_config.yaml b/docs/sample_config.yaml
index 05506a7787..a6fbcc6080 100644
--- a/docs/sample_config.yaml
+++ b/docs/sample_config.yaml
@@ -1801,7 +1801,8 @@ saml2_config:
#
# localpart_template: Jinja2 template for the localpart of the MXID.
# If this is not set, the user will be prompted to choose their
-# own username.
+# own username (see 'sso_auth_account_details.html' in the 'sso'
+# section of this file).
#
# display_name_template: Jinja2 template for the display name to set
# on first login. If unset, no displayname will be set.
@@ -1968,6 +1969,35 @@ sso:
#
# * idp: the 'idp_id' of the chosen IDP.
#
+ # * HTML page to prompt new users to enter a userid and confirm other
+ # details: 'sso_auth_account_details.html'. This is only shown if the
+ # SSO implementation (with any user_mapping_provider) does not return
+ # a localpart.
+ #
+ # When rendering, this template is given the following variables:
+ #
+ # * server_name: the homeserver's name.
+ #
+ # * idp: details of the SSO Identity Provider that the user logged in
+ # with: an object with the following attributes:
+ #
+ # * idp_id: unique identifier for the IdP
+ # * idp_name: user-facing name for the IdP
+ # * idp_icon: if specified in the IdP config, an MXC URI for an icon
+ # for the IdP
+ # * idp_brand: if specified in the IdP config, a textual identifier
+ # for the brand of the IdP
+ #
+ # * user_attributes: an object containing details about the user that
+ # we received from the IdP. May have the following attributes:
+ #
+ # * display_name: the user's display_name
+ # * emails: a list of email addresses
+ #
+ # The template should render a form which submits the following fields:
+ #
+ # * username: the localpart of the user's chosen user id
+ #
# * HTML page for a confirmation step before redirecting back to the client
# with the login token: 'sso_redirect_confirm.html'.
#
diff --git a/synapse/config/_base.py b/synapse/config/_base.py
index 94144efc87..35e5594b73 100644
--- a/synapse/config/_base.py
+++ b/synapse/config/_base.py
@@ -18,18 +18,18 @@
import argparse
import errno
import os
-import time
-import urllib.parse
from collections import OrderedDict
from hashlib import sha256
from textwrap import dedent
-from typing import Any, Callable, Iterable, List, MutableMapping, Optional
+from typing import Any, Iterable, List, MutableMapping, Optional
import attr
import jinja2
import pkg_resources
import yaml
+from synapse.util.templates import _create_mxc_to_http_filter, _format_ts_filter
+
class ConfigError(Exception):
"""Represents a problem parsing the configuration
@@ -248,6 +248,7 @@ class Config:
# Search the custom template directory as well
search_directories.insert(0, custom_template_directory)
+ # TODO: switch to synapse.util.templates.build_jinja_env
loader = jinja2.FileSystemLoader(search_directories)
env = jinja2.Environment(loader=loader, autoescape=autoescape)
@@ -267,38 +268,6 @@ class Config:
return templates
-def _format_ts_filter(value: int, format: str):
- return time.strftime(format, time.localtime(value / 1000))
-
-
-def _create_mxc_to_http_filter(public_baseurl: str) -> Callable:
- """Create and return a jinja2 filter that converts MXC urls to HTTP
-
- Args:
- public_baseurl: The public, accessible base URL of the homeserver
- """
-
- def mxc_to_http_filter(value, width, height, resize_method="crop"):
- if value[0:6] != "mxc://":
- return ""
-
- server_and_media_id = value[6:]
- fragment = None
- if "#" in server_and_media_id:
- server_and_media_id, fragment = server_and_media_id.split("#", 1)
- fragment = "#" + fragment
-
- params = {"width": width, "height": height, "method": resize_method}
- return "%s_matrix/media/v1/thumbnail/%s?%s%s" % (
- public_baseurl,
- server_and_media_id,
- urllib.parse.urlencode(params),
- fragment or "",
- )
-
- return mxc_to_http_filter
-
-
class RootConfig:
"""
Holder of an application's configuration.
diff --git a/synapse/config/oidc_config.py b/synapse/config/oidc_config.py
index f31511e039..784b416f95 100644
--- a/synapse/config/oidc_config.py
+++ b/synapse/config/oidc_config.py
@@ -152,7 +152,8 @@ class OIDCConfig(Config):
#
# localpart_template: Jinja2 template for the localpart of the MXID.
# If this is not set, the user will be prompted to choose their
- # own username.
+ # own username (see 'sso_auth_account_details.html' in the 'sso'
+ # section of this file).
#
# display_name_template: Jinja2 template for the display name to set
# on first login. If unset, no displayname will be set.
diff --git a/synapse/config/sso.py b/synapse/config/sso.py
index a470112ed4..e308fc9333 100644
--- a/synapse/config/sso.py
+++ b/synapse/config/sso.py
@@ -27,7 +27,7 @@ class SSOConfig(Config):
sso_config = config.get("sso") or {} # type: Dict[str, Any]
# The sso-specific template_dir
- template_dir = sso_config.get("template_dir")
+ self.sso_template_dir = sso_config.get("template_dir")
# Read templates from disk
(
@@ -48,7 +48,7 @@ class SSOConfig(Config):
"sso_auth_success.html",
"sso_auth_bad_user.html",
],
- template_dir,
+ self.sso_template_dir,
)
# These templates have no placeholders, so render them here
@@ -124,6 +124,35 @@ class SSOConfig(Config):
#
# * idp: the 'idp_id' of the chosen IDP.
#
+ # * HTML page to prompt new users to enter a userid and confirm other
+ # details: 'sso_auth_account_details.html'. This is only shown if the
+ # SSO implementation (with any user_mapping_provider) does not return
+ # a localpart.
+ #
+ # When rendering, this template is given the following variables:
+ #
+ # * server_name: the homeserver's name.
+ #
+ # * idp: details of the SSO Identity Provider that the user logged in
+ # with: an object with the following attributes:
+ #
+ # * idp_id: unique identifier for the IdP
+ # * idp_name: user-facing name for the IdP
+ # * idp_icon: if specified in the IdP config, an MXC URI for an icon
+ # for the IdP
+ # * idp_brand: if specified in the IdP config, a textual identifier
+ # for the brand of the IdP
+ #
+ # * user_attributes: an object containing details about the user that
+ # we received from the IdP. May have the following attributes:
+ #
+ # * display_name: the user's display_name
+ # * emails: a list of email addresses
+ #
+ # The template should render a form which submits the following fields:
+ #
+ # * username: the localpart of the user's chosen user id
+ #
# * HTML page for a confirmation step before redirecting back to the client
# with the login token: 'sso_redirect_confirm.html'.
#
diff --git a/synapse/handlers/sso.py b/synapse/handlers/sso.py
index ceaeb5a376..ff4750999a 100644
--- a/synapse/handlers/sso.py
+++ b/synapse/handlers/sso.py
@@ -530,7 +530,7 @@ class SsoHandler:
logger.info("Recorded registration session id %s", session_id)
# Set the cookie and redirect to the username picker
- e = RedirectException(b"/_synapse/client/pick_username")
+ e = RedirectException(b"/_synapse/client/pick_username/account_details")
e.cookies.append(
b"%s=%s; path=/"
% (USERNAME_MAPPING_SESSION_COOKIE_NAME, session_id.encode("ascii"))
diff --git a/synapse/res/templates/sso_auth_account_details.html b/synapse/res/templates/sso_auth_account_details.html
new file mode 100644
index 0000000000..f22b09aec1
--- /dev/null
+++ b/synapse/res/templates/sso_auth_account_details.html
@@ -0,0 +1,115 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <title>Synapse Login</title>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, user-scalable=no">
+ <style type="text/css">
+ {% include "sso.css" without context %}
+
+ .username_input {
+ display: flex;
+ border: 2px solid #418DED;
+ border-radius: 8px;
+ padding: 12px;
+ position: relative;
+ margin: 16px 0;
+ align-items: center;
+ font-size: 12px;
+ }
+
+ .username_input label {
+ position: absolute;
+ top: -8px;
+ left: 14px;
+ font-size: 80%;
+ background: white;
+ padding: 2px;
+ }
+
+ .username_input input {
+ flex: 1;
+ display: block;
+ min-width: 0;
+ border: none;
+ }
+
+ .username_input div {
+ color: #8D99A5;
+ }
+
+ .idp-pick-details {
+ border: 1px solid #E9ECF1;
+ border-radius: 8px;
+ margin: 24px 0;
+ }
+
+ .idp-pick-details h2 {
+ margin: 0;
+ padding: 8px 12px;
+ }
+
+ .idp-pick-details .idp-detail {
+ border-top: 1px solid #E9ECF1;
+ padding: 12px;
+ }
+
+ .idp-pick-details .use, .idp-pick-details .idp-value {
+ color: #737D8C;
+ }
+
+ .idp-pick-details .idp-value {
+ margin: 0;
+ margin-top: 8px;
+ }
+
+ .idp-pick-details .avatar {
+ width: 53px;
+ height: 53px;
+ border-radius: 100%;
+ display: block;
+ margin-top: 8px;
+ }
+ </style>
+ </head>
+ <body>
+ <header>
+ <h1>Your account is nearly ready</h1>
+ <p>Check your details before creating an account on {{ server_name }}</p>
+ </header>
+ <main>
+ <form method="post" class="form__input" id="form">
+ <div class="username_input">
+ <label for="field-username">Username</label>
+ <div class="prefix">@</div>
+ <input type="text" name="username" id="field-username" autofocus required pattern="[a-z0-9\-=_\/\.]+">
+ <div class="postfix">:{{ server_name }}</div>
+ </div>
+ <input type="submit" value="Continue" class="primary-button">
+ {% if user_attributes %}
+ <section class="idp-pick-details">
+ <h2><img src="{{ idp.idp_icon | mxc_to_http(24, 24) }}"/>Information from {{ idp.idp_name }}</h2>
+ {% if user_attributes.avatar_url %}
+ <div class="idp-detail idp-avatar">
+ <img src="{{ user_attributes.avatar_url }}" class="avatar" />
+ </div>
+ {% endif %}
+ {% if user_attributes.display_name %}
+ <div class="idp-detail">
+ <p class="idp-value">{{ user_attributes.display_name }}</p>
+ </div>
+ {% endif %}
+ {% for email in user_attributes.emails %}
+ <div class="idp-detail">
+ <p class="idp-value">{{ email }}</p>
+ </div>
+ {% endfor %}
+ </section>
+ {% endif %}
+ </form>
+ </main>
+ <script type="text/javascript">
+ {% include "sso_auth_account_details.js" without context %}
+ </script>
+ </body>
+</html>
diff --git a/synapse/res/templates/sso_auth_account_details.js b/synapse/res/templates/sso_auth_account_details.js
new file mode 100644
index 0000000000..deef419bb6
--- /dev/null
+++ b/synapse/res/templates/sso_auth_account_details.js
@@ -0,0 +1,76 @@
+const usernameField = document.getElementById("field-username");
+
+function throttle(fn, wait) {
+ let timeout;
+ return function() {
+ const args = Array.from(arguments);
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+ timeout = setTimeout(fn.bind.apply(fn, [null].concat(args)), wait);
+ }
+}
+
+function checkUsernameAvailable(username) {
+ let check_uri = 'check?username=' + encodeURIComponent(username);
+ return fetch(check_uri, {
+ // include the cookie
+ "credentials": "same-origin",
+ }).then((response) => {
+ if(!response.ok) {
+ // for non-200 responses, raise the body of the response as an exception
+ return response.text().then((text) => { throw new Error(text); });
+ } else {
+ return response.json();
+ }
+ }).then((json) => {
+ if(json.error) {
+ return {message: json.error};
+ } else if(json.available) {
+ return {available: true};
+ } else {
+ return {message: username + " is not available, please choose another."};
+ }
+ });
+}
+
+function validateUsername(username) {
+ usernameField.setCustomValidity("");
+ if (usernameField.validity.valueMissing) {
+ usernameField.setCustomValidity("Please provide a username");
+ return;
+ }
+ if (usernameField.validity.patternMismatch) {
+ usernameField.setCustomValidity("Invalid username, please only use " + allowedCharactersString);
+ return;
+ }
+ usernameField.setCustomValidity("Checking if username is available …");
+ throttledCheckUsernameAvailable(username);
+}
+
+const throttledCheckUsernameAvailable = throttle(function(username) {
+ const handleError = function(err) {
+ // don't prevent form submission on error
+ usernameField.setCustomValidity("");
+ console.log(err.message);
+ };
+ try {
+ checkUsernameAvailable(username).then(function(result) {
+ if (!result.available) {
+ usernameField.setCustomValidity(result.message);
+ usernameField.reportValidity();
+ } else {
+ usernameField.setCustomValidity("");
+ }
+ }, handleError);
+ } catch (err) {
+ handleError(err);
+ }
+}, 500);
+
+usernameField.addEventListener("input", function(evt) {
+ validateUsername(usernameField.value);
+});
+usernameField.addEventListener("change", function(evt) {
+ validateUsername(usernameField.value);
+});
diff --git a/synapse/res/username_picker/index.html b/synapse/res/username_picker/index.html
deleted file mode 100644
index 37ea8bb6d8..0000000000
--- a/synapse/res/username_picker/index.html
+++ /dev/null
@@ -1,19 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
- <head>
- <title>Synapse Login</title>
- <link rel="stylesheet" href="style.css" type="text/css" />
- </head>
- <body>
- <div class="card">
- <form method="post" class="form__input" id="form" action="submit">
- <label for="field-username">Please pick your username:</label>
- <input type="text" name="username" id="field-username" autofocus="">
- <input type="submit" class="button button--full-width" id="button-submit" value="Submit">
- </form>
- <!-- this is used for feedback -->
- <div role=alert class="tooltip hidden" id="message"></div>
- <script src="script.js"></script>
- </div>
- </body>
-</html>
diff --git a/synapse/res/username_picker/script.js b/synapse/res/username_picker/script.js
deleted file mode 100644
index 416a7c6f41..0000000000
--- a/synapse/res/username_picker/script.js
+++ /dev/null
@@ -1,95 +0,0 @@
-let inputField = document.getElementById("field-username");
-let inputForm = document.getElementById("form");
-let submitButton = document.getElementById("button-submit");
-let message = document.getElementById("message");
-
-// Submit username and receive response
-function showMessage(messageText) {
- // Unhide the message text
- message.classList.remove("hidden");
-
- message.textContent = messageText;
-};
-
-function doSubmit() {
- showMessage("Success. Please wait a moment for your browser to redirect.");
-
- // remove the event handler before re-submitting the form.
- delete inputForm.onsubmit;
- inputForm.submit();
-}
-
-function onResponse(response) {
- // Display message
- showMessage(response);
-
- // Enable submit button and input field
- submitButton.classList.remove('button--disabled');
- submitButton.value = "Submit";
-};
-
-let allowedUsernameCharacters = RegExp("[^a-z0-9\\.\\_\\=\\-\\/]");
-function usernameIsValid(username) {
- return !allowedUsernameCharacters.test(username);
-}
-let allowedCharactersString = "lowercase letters, digits, ., _, -, /, =";
-
-function buildQueryString(params) {
- return Object.keys(params)
- .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
- .join('&');
-}
-
-function submitUsername(username) {
- if(username.length == 0) {
- onResponse("Please enter a username.");
- return;
- }
- if(!usernameIsValid(username)) {
- onResponse("Invalid username. Only the following characters are allowed: " + allowedCharactersString);
- return;
- }
-
- // if this browser doesn't support fetch, skip the availability check.
- if(!window.fetch) {
- doSubmit();
- return;
- }
-
- let check_uri = 'check?' + buildQueryString({"username": username});
- fetch(check_uri, {
- // include the cookie
- "credentials": "same-origin",
- }).then((response) => {
- if(!response.ok) {
- // for non-200 responses, raise the body of the response as an exception
- return response.text().then((text) => { throw text; });
- } else {
- return response.json();
- }
- }).then((json) => {
- if(json.error) {
- throw json.error;
- } else if(json.available) {
- doSubmit();
- } else {
- onResponse("This username is not available, please choose another.");
- }
- }).catch((err) => {
- onResponse("Error checking username availability: " + err);
- });
-}
-
-function clickSubmit() {
- event.preventDefault();
- if(submitButton.classList.contains('button--disabled')) { return; }
-
- // Disable submit button and input field
- submitButton.classList.add('button--disabled');
-
- // Submit username
- submitButton.value = "Checking...";
- submitUsername(inputField.value);
-};
-
-inputForm.onsubmit = clickSubmit;
diff --git a/synapse/res/username_picker/style.css b/synapse/res/username_picker/style.css
deleted file mode 100644
index 745bd4c684..0000000000
--- a/synapse/res/username_picker/style.css
+++ /dev/null
@@ -1,27 +0,0 @@
-input[type="text"] {
- font-size: 100%;
- background-color: #ededf0;
- border: 1px solid #fff;
- border-radius: .2em;
- padding: .5em .9em;
- display: block;
- width: 26em;
-}
-
-.button--disabled {
- border-color: #fff;
- background-color: transparent;
- color: #000;
- text-transform: none;
-}
-
-.hidden {
- display: none;
-}
-
-.tooltip {
- background-color: #f9f9fa;
- padding: 1em;
- margin: 1em 0;
-}
-
diff --git a/synapse/rest/consent/consent_resource.py b/synapse/rest/consent/consent_resource.py
index b3e4d5612e..8b9ef26cf2 100644
--- a/synapse/rest/consent/consent_resource.py
+++ b/synapse/rest/consent/consent_resource.py
@@ -100,6 +100,7 @@ class ConsentResource(DirectServeHtmlResource):
consent_template_directory = hs.config.user_consent_template_dir
+ # TODO: switch to synapse.util.templates.build_jinja_env
loader = jinja2.FileSystemLoader(consent_template_directory)
self._jinja_env = jinja2.Environment(
loader=loader, autoescape=jinja2.select_autoescape(["html", "htm", "xml"])
diff --git a/synapse/rest/synapse/client/pick_username.py b/synapse/rest/synapse/client/pick_username.py
index 1bc737bad0..27540d3bbe 100644
--- a/synapse/rest/synapse/client/pick_username.py
+++ b/synapse/rest/synapse/client/pick_username.py
@@ -13,41 +13,41 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+import logging
from typing import TYPE_CHECKING
-import pkg_resources
-
from twisted.web.http import Request
from twisted.web.resource import Resource
-from twisted.web.static import File
+from synapse.api.errors import SynapseError
from synapse.handlers.sso import get_username_mapping_session_cookie_from_request
-from synapse.http.server import DirectServeHtmlResource, DirectServeJsonResource
+from synapse.http.server import (
+ DirectServeHtmlResource,
+ DirectServeJsonResource,
+ respond_with_html,
+)
from synapse.http.servlet import parse_string
from synapse.http.site import SynapseRequest
+from synapse.util.templates import build_jinja_env
if TYPE_CHECKING:
from synapse.server import HomeServer
+logger = logging.getLogger(__name__)
+
def pick_username_resource(hs: "HomeServer") -> Resource:
"""Factory method to generate the username picker resource.
- This resource gets mounted under /_synapse/client/pick_username. The top-level
- resource is just a File resource which serves up the static files in the resources
- "res" directory, but it has a couple of children:
+ This resource gets mounted under /_synapse/client/pick_username and has two
+ children:
- * "submit", which does the mechanics of registering the new user, and redirects the
- browser back to the client URL
-
- * "check": checks if a userid is free.
+ * "account_details": renders the form and handles the POSTed response
+ * "check": a JSON endpoint which checks if a userid is free.
"""
- # XXX should we make this path customisable so that admins can restyle it?
- base_path = pkg_resources.resource_filename("synapse", "res/username_picker")
-
- res = File(base_path)
- res.putChild(b"submit", SubmitResource(hs))
+ res = Resource()
+ res.putChild(b"account_details", AccountDetailsResource(hs))
res.putChild(b"check", AvailabilityCheckResource(hs))
return res
@@ -69,15 +69,54 @@ class AvailabilityCheckResource(DirectServeJsonResource):
return 200, {"available": is_available}
-class SubmitResource(DirectServeHtmlResource):
+class AccountDetailsResource(DirectServeHtmlResource):
def __init__(self, hs: "HomeServer"):
super().__init__()
self._sso_handler = hs.get_sso_handler()
- async def _async_render_POST(self, request: SynapseRequest):
- localpart = parse_string(request, "username", required=True)
+ def template_search_dirs():
+ if hs.config.sso.sso_template_dir:
+ yield hs.config.sso.sso_template_dir
+ yield hs.config.sso.default_template_dir
+
+ self._jinja_env = build_jinja_env(template_search_dirs(), hs.config)
+
+ async def _async_render_GET(self, request: Request) -> None:
+ try:
+ session_id = get_username_mapping_session_cookie_from_request(request)
+ session = self._sso_handler.get_mapping_session(session_id)
+ except SynapseError as e:
+ logger.warning("Error fetching session: %s", e)
+ self._sso_handler.render_error(request, "bad_session", e.msg, code=e.code)
+ return
+
+ idp_id = session.auth_provider_id
+ template_params = {
+ "idp": self._sso_handler.get_identity_providers()[idp_id],
+ "user_attributes": {
+ "display_name": session.display_name,
+ "emails": session.emails,
+ },
+ }
+
+ template = self._jinja_env.get_template("sso_auth_account_details.html")
+ html = template.render(template_params)
+ respond_with_html(request, 200, html)
- session_id = get_username_mapping_session_cookie_from_request(request)
+ async def _async_render_POST(self, request: SynapseRequest):
+ try:
+ session_id = get_username_mapping_session_cookie_from_request(request)
+ except SynapseError as e:
+ logger.warning("Error fetching session cookie: %s", e)
+ self._sso_handler.render_error(request, "bad_session", e.msg, code=e.code)
+ return
+
+ try:
+ localpart = parse_string(request, "username", required=True)
+ except SynapseError as e:
+ logger.warning("[session %s] bad param: %s", session_id, e)
+ self._sso_handler.render_error(request, "bad_param", e.msg, code=e.code)
+ return
await self._sso_handler.handle_submit_username_request(
request, localpart, session_id
diff --git a/synapse/util/templates.py b/synapse/util/templates.py
new file mode 100644
index 0000000000..7e5109d206
--- /dev/null
+++ b/synapse/util/templates.py
@@ -0,0 +1,106 @@
+# -*- coding: utf-8 -*-
+# Copyright 2021 The Matrix.org Foundation C.I.C.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Utilities for dealing with jinja2 templates"""
+
+import time
+import urllib.parse
+from typing import TYPE_CHECKING, Callable, Iterable, Union
+
+import jinja2
+
+if TYPE_CHECKING:
+ from synapse.config.homeserver import HomeServerConfig
+
+
+def build_jinja_env(
+ template_search_directories: Iterable[str],
+ config: "HomeServerConfig",
+ autoescape: Union[bool, Callable[[str], bool], None] = None,
+) -> jinja2.Environment:
+ """Set up a Jinja2 environment to load templates from the given search path
+
+ The returned environment defines the following filters:
+ - format_ts: formats timestamps as strings in the server's local timezone
+ (XXX: why is that useful??)
+ - mxc_to_http: converts mxc: uris to http URIs. Args are:
+ (uri, width, height, resize_method="crop")
+
+ and the following global variables:
+ - server_name: matrix server name
+
+ Args:
+ template_search_directories: directories to search for templates
+
+ config: homeserver config, for things like `server_name` and `public_baseurl`
+
+ autoescape: whether template variables should be autoescaped. bool, or
+ a function mapping from template name to bool. Defaults to escaping templates
+ whose names end in .html, .xml or .htm.
+
+ Returns:
+ jinja environment
+ """
+
+ if autoescape is None:
+ autoescape = jinja2.select_autoescape()
+
+ loader = jinja2.FileSystemLoader(template_search_directories)
+ env = jinja2.Environment(loader=loader, autoescape=autoescape)
+
+ # Update the environment with our custom filters
+ env.filters.update(
+ {
+ "format_ts": _format_ts_filter,
+ "mxc_to_http": _create_mxc_to_http_filter(config.public_baseurl),
+ }
+ )
+
+ # common variables for all templates
+ env.globals.update({"server_name": config.server_name})
+
+ return env
+
+
+def _create_mxc_to_http_filter(public_baseurl: str) -> Callable:
+ """Create and return a jinja2 filter that converts MXC urls to HTTP
+
+ Args:
+ public_baseurl: The public, accessible base URL of the homeserver
+ """
+
+ def mxc_to_http_filter(value, width, height, resize_method="crop"):
+ if value[0:6] != "mxc://":
+ return ""
+
+ server_and_media_id = value[6:]
+ fragment = None
+ if "#" in server_and_media_id:
+ server_and_media_id, fragment = server_and_media_id.split("#", 1)
+ fragment = "#" + fragment
+
+ params = {"width": width, "height": height, "method": resize_method}
+ return "%s_matrix/media/v1/thumbnail/%s?%s%s" % (
+ public_baseurl,
+ server_and_media_id,
+ urllib.parse.urlencode(params),
+ fragment or "",
+ )
+
+ return mxc_to_http_filter
+
+
+def _format_ts_filter(value: int, format: str):
+ return time.strftime(format, time.localtime(value / 1000))
diff --git a/tests/rest/client/v1/test_login.py b/tests/rest/client/v1/test_login.py
index ded22a9767..66dfdaffbc 100644
--- a/tests/rest/client/v1/test_login.py
+++ b/tests/rest/client/v1/test_login.py
@@ -1222,7 +1222,7 @@ class UsernamePickerTestCase(HomeserverTestCase):
# that should redirect to the username picker
self.assertEqual(channel.code, 302, channel.result)
picker_url = channel.headers.getRawHeaders("Location")[0]
- self.assertEqual(picker_url, "/_synapse/client/pick_username")
+ self.assertEqual(picker_url, "/_synapse/client/pick_username/account_details")
# ... with a username_mapping_session cookie
cookies = {} # type: Dict[str,str]
@@ -1247,11 +1247,10 @@ class UsernamePickerTestCase(HomeserverTestCase):
# Now, submit a username to the username picker, which should serve a redirect
# to the completion page
- submit_path = picker_url + "/submit"
content = urlencode({b"username": b"bobby"}).encode("utf8")
chan = self.make_request(
"POST",
- path=submit_path,
+ path=picker_url,
content=content,
content_is_form=True,
custom_headers=[
|