Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Replace username picker with a template #9275

Merged
merged 7 commits into from
Feb 1, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/9275.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improve the user experience of setting up an account via single-sign on.
32 changes: 31 additions & 1 deletion docs/sample_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Comment on lines +1991 to +1992
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The template seems to also handle avatar_url, although none of our SSO code gets that right now.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indeed it does. It's "future-proof".

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright! Does it make sense to document it now or "in the future"? 😄

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should document it here until it's supported.

#
# * 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'.
#
Expand Down
39 changes: 4 additions & 35 deletions synapse/config/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -248,6 +248,7 @@ def read_templates(
# 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)

Expand All @@ -267,38 +268,6 @@ def read_templates(
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.
Expand Down
3 changes: 2 additions & 1 deletion synapse/config/oidc_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ def generate_config_section(self, config_dir_path, server_name, **kwargs):
#
# 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.
Expand Down
33 changes: 31 additions & 2 deletions synapse/config/sso.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def read_config(self, config, **kwargs):
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
(
Expand All @@ -48,7 +48,7 @@ def read_config(self, config, **kwargs):
"sso_auth_success.html",
"sso_auth_bad_user.html",
],
template_dir,
self.sso_template_dir,
)

# These templates have no placeholders, so render them here
Expand Down Expand Up @@ -124,6 +124,35 @@ def generate_config_section(self, **kwargs):
#
# * 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'.
#
Expand Down
2 changes: 1 addition & 1 deletion synapse/handlers/sso.py
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ async def _redirect_to_username_picker(
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"))
Expand Down
115 changes: 115 additions & 0 deletions synapse/res/templates/sso_auth_account_details.html
Original file line number Diff line number Diff line change
@@ -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>
76 changes: 76 additions & 0 deletions synapse/res/templates/sso_auth_account_details.js
Original file line number Diff line number Diff line change
@@ -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("");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL this is a thing. 👍

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);
});
Loading