Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 3 additions & 3 deletions readthedocsext/theme/static/readthedocsext/theme/js/site.js

Large diffs are not rendered by default.

Large diffs are not rendered by default.

98 changes: 47 additions & 51 deletions readthedocsext/theme/templates/account/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,57 +19,56 @@
{% block content_body %}
{% get_providers as socialaccount_providers %}

<div class="ui basic horizontally fitted segment">
<div class="ui form">
<div class="field">
<label>
{% block authentication_header_text %}
{% trans "Log in using:" %}
{% endblock authentication_header_text %}
</label>
<div data-bind="using: LoginView({last_tab: '{{ last_login_tab }}', last_method: '{{ last_login_method }}'})">
<div class="ui basic horizontally fitted segment">
<div class="ui form">
<div class="field">
<label>
{% block authentication_header_text %}
{% trans "Log in using:" %}
{% endblock authentication_header_text %}
</label>
</div>
</div>
</div>

<div class="ui small stackable teal centered fluid wrapping menu" data-bind="semanticui: {tabs: {
{% if last_login_tab %}autoTabActivation: '{{ last_login_tab }}'{% endif %}
}}">

<a class="item" data-tab="vcs">
<i class="fas fa-cloud icon"></i>
{% block authentication_vcs_text %}
{% trans "Connected account" %}
{% endblock authentication_vcs_text %}
</a>

{# If allowed providers is given, disable the email menu item #}
<a class="{% if allowed_providers %}disabled{% endif %} item"
{% if last_login_tab == "email" %} data-tooltip="Last used" data-variation="teal visible" data-position="top center" {% endif %}
data-tab="email">
<i class="fas fa-envelope icon"></i>
{% block authentication_email_text %}
{% trans "Email" %}
{% endblock authentication_email_text %}
</a>

{% if USE_ORGANIZATIONS %}
<a class="item" data-tab="sso">
<i class="fas fa-shield-alt icon"></i>
{% trans "Single sign-on" %}
<div class="ui small stackable teal centered fluid wrapping menu"
data-bind="semanticui: {tabs: { autoTabActivation: last_tab() }}">
Copy link
Member

Choose a reason for hiding this comment

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

last_tab() here is accessing to LoginView.last_tab variable we set before, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's not, Knockout has observable declaration order mostly handled for us, and one of the reasons we want to stick to using single patterns. The data bind layer will only be activated within KO, as the view executes, but the view will also wait for all of the view observables to resolve before allowing declaring the value to this binding.

It is possible to introduce race conditions when setting an observable value async, here you can notice the binding resolving more than once. For example an observable set from an API response, view instantiation will fire one variable change event for an undefined/default value and then another event later for the API response value.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just to clarify, the order of this is: everything in data-bind is evaluated after the view has been initialized, at which point the last_tab() observable has initialized with a value.


<a class="item" data-tab="vcs">
<i class="fas fa-cloud icon"></i>
{% block authentication_vcs_text %}
{% trans "Connected account" %}
{% endblock authentication_vcs_text %}
</a>
{% endif %}

{% if project_password_url %}
<a class="item" href="{{ project_password_url }}">
<i class="fas fa-lock icon"></i>
{% block authentication_password_text %}
{% trans "Password" %}
{% endblock authentication_password_text %}

{# If allowed providers is given, disable the email menu item #}
<a class="{% if allowed_providers %}disabled{% endif %} item"
data-bind="semanticui: { popup: popup_login_method('email') }"
data-tab="email">
<i class="fas fa-envelope icon"></i>
{% block authentication_email_text %}
{% trans "Email" %}
{% endblock authentication_email_text %}
</a>
{% endif %}

{% if USE_ORGANIZATIONS %}
<a class="item" data-tab="sso">
<i class="fas fa-shield-alt icon"></i>
{% trans "Single sign-on" %}
</a>
{% endif %}

{% if project_password_url %}
<a class="item" href="{{ project_password_url }}">
<i class="fas fa-lock icon"></i>
{% block authentication_password_text %}
{% trans "Password" %}
{% endblock authentication_password_text %}
</a>
{% endif %}
</div>
</div>
</div>

<div data-bind="using: LoginView()">
<div class="ui basic center aligned tab segment" data-tab="vcs">
<div class="ui relaxed list">
{% block authentication_vcs %}
Expand Down Expand Up @@ -103,8 +102,7 @@
<a class="item" href="{% url 'account_reset_password' %}">{% trans "Forgot your password?" %}</a>
<div class="right menu">
<button class="ui primary button"
data-bind="click: save_login_method"
data-provider="email"
data-bind="click: save_login_method.bind($data, 'email')"
type="submit">{% trans "Log in" %}</button>
</div>
</div>
Expand All @@ -118,11 +116,9 @@
<a href="{% url "saml_resolve_login" %}{% if redirect_field_value %}?{{ redirect_field_name }}={{ redirect_field_value }}{% endif %}">

<button class="ui button"
data-bind="click: save_login_method"
data-provider="sso"
type="submit"
{% if last_login_method == "sso" %} data-tooltip="Last used" data-variation="teal visible" data-position="right center" {% endif %}
title="Single sign-on">
data-bind="click: save_login_method.bind($data, 'sso'), semanticui: { popup: popup_login_method('sso') }"
title="{% trans "Single sign-on" %}">
<i class="fas fa-shield-alt icon" aria-hidden="true"></i>
{% trans "Log in using single sign-on" %}
</button>
Expand Down
26 changes: 14 additions & 12 deletions readthedocsext/theme/templates/socialaccount/connections.html
Original file line number Diff line number Diff line change
@@ -1,35 +1,37 @@
{% extends "profiles/base_edit.html" %}

{% load i18n %}
{% load trans blocktrans from i18n %}

{% block title %}{% trans "Connected services" %}{% endblock %}
{% block edit_content_header %}{% trans "Connected services" %}{% endblock %}
{% block profile_admin_social_accounts %}active{% endblock %}
{% block title %}
{% trans "Connected services" %}
{% endblock title %}
{% block edit_content_header %}
{% trans "Connected services" %}
{% endblock edit_content_header %}
{% block profile_admin_social_accounts %}
active
{% endblock profile_admin_social_accounts %}

{% block edit_content %}
{% include "socialaccount/partials/social_account_list.html" with objects=form.accounts %}

<div class="ui tiny modal" data-modal-id="socialaccount-connections">
<i class="fa-solid fa-close close icon"></i>
<div class="header">
{% trans "Add a connection" %}
</div>
<div class="header">{% trans "Add a connection" %}</div>
<div class="content center aligned">
<div class="ui basic center aligned segment">
<p>
{% blocktrans trimmed %}
You can connect your account with any of the following providers:
{% endblocktrans %}
</p>
<div class="ui relaxed list">
<div class="ui relaxed list" data-bind="using: SocialAccountView()">
{% include "socialaccount/snippets/provider_list.html" with process="connect" next="" %}
</div>
</div>
</div>
<div class="actions">
<div class="ui cancel button">
{% trans "Cancel" %}
</div>
<div class="ui cancel button">{% trans "Cancel" %}</div>
</div>
</div>
{% endblock %}
{% endblock edit_content %}
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,17 @@
{% trans "GitHub" as provider_name %}
<li class="item">
<a class="ui button"
{% if last_login_method == "github" or last_login_method == "githubapp" %} data-tooltip="Last used" data-variation="teal visible" data-position="right center" {% endif %}
data-bind="click: $root.show_modal('github-select')"
data-bind="click: show_github_modal, semanticui: { popup: popup_login_method(['github','githubapp'], 'right center') }"
title="{{ provider_name }}">
<i class="fa-brands fa-github icon"></i>
{% blocktrans trimmed with provider_name=provider_name verbiage=verbiage|default:'Connect to' %}
{{ verbiage }} {{ provider_name }}
{% endblocktrans %}
</a>
</li>
<div class="ui tiny modal" data-modal-id="github-select">

<div class="ui tiny modal"
data-bind="semanticui: { modal: github_modal_config() }">
<div class="header">
{% blocktrans trimmed with verbiage=verbiage|default:'Connect to' provider_name=provider_name %}
{{ verbiage }} {{ provider_name }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,8 @@

{# djlint: off D018 #}
<button class="ui {{ button_classes }} button"
data-bind="click: save_login_method"
data-provider="{{ provider.id }}"
type="submit"
{% if last_login_method == provider.id|lower %} data-tooltip="Last used" data-variation="teal visible" data-position="right center" {% endif %}
title="{{ provider.name }}">
data-bind="click: save_login_method.bind($data, '{{ provider.id }}'), semanticui: { popup: popup_login_method('{{ provider.id }}', 'right center') }"
type="submit">
{# djlint: on #}

<i class="fa-brands fa-{{ provider.name|lower }} icon"></i>
Expand Down
119 changes: 105 additions & 14 deletions src/js/account/index.js
Original file line number Diff line number Diff line change
@@ -1,51 +1,142 @@
import ko from "knockout";
import { msg } from "@lit/localize";

import { Registry } from "../application/registry";

/**
* LoginView manages login-related functionality including saving the
* last used login method to a cookie for session persistence.
* Listing view for social account connections.
*
* This view is subclassed by the :js:cls:`LoginView`, as the templates that
* use this code are shared between the social account connection listing view
* and the login view. On the social account listing we don't want the same
* features for last login method etc so this view overloads the functions
* used.
*/
export class SocialAccountView {
static view_name = "SocialAccountView";

constructor(options) {
this.github_modal_config = ko.observable();
}

show_github_modal() {
this.github_modal_config((modal) => modal("show"));
}

// No-op to skip popup setup
popup_login_method() {}

save_login_method() {
return true;
}
}

/**
* LoginView saves a cookie for the last login method.
*
* Usage in a binding context:
* Usage:
*
* .. code:: html
*
* <div data-bind="using: LoginView()">
* <form method="post" action="...">
* <button data-bind="click: save_login_method" data-provider="github">
* <button data-bind="click: save_login_method.bind('email'), semanticui: { popup: popup_login_method('email') }">
* Log in using GitHub
* </button>
* </form>
* </div>
*/
export class LoginView {
export class LoginView extends SocialAccountView {
static view_name = "LoginView";

constructor() {}
constructor(options) {
super(options);

/** @observable {string} Last tab to be selected. Comes from view */
this.last_tab = ko.observable(options?.last_tab || "vcs");
/** @observable {string} Last method to be used. Comes from view */
// XXX fix default, this is just for testing
this.last_method = ko.observable(options?.last_method || "githubapp");

// This is an named lookup for observables, one for each method. It is
// populated by the template code as each popup is configured.
this.popups = {};
}

/**
* Save the provider used for login.
* Add popup for login method
*
* This adds an observable using by the semanticui binding to add and
* manipulate a popup module on the element.
*
* Accepts multiple method ids just because we have a GitHub sub-modal right
* now. This can be removed eventually.
*
* @param {string|Array.<string>} method - Method id or list of method ids
* @param {string} position - Position to pass to SUI popup position attribute
* @param {string} method - Method id
*/
popup_login_method(method, position = "top center", fuzzy = true) {
this.popups[method] = ko.observable((popup) => {
// This is using the KO data binding method for utilizing SUI module
// behaviors. Passing this an anonymous function provides access to
// `$(element).popup()` essentially.
// First set up the element as a manual popup, then manually show it.
popup({
content: msg(`Last used`),
position: position,
variation: "mini teal",
closable: true,
preserve: true,
on: "manual",
});
if (!Array.isArray(method) && this.last_method() == method) {
popup("show");
} else if (Array.isArray(method) && method.includes(this.last_method())) {
popup("show");
}
});
return this.popups[method]();
}

/**
* Save a cookie to track last login method.
*
* This could be used like:
*
* .. code:: html
*
* <form method="post" action="...">
* <button data-bind="click: save_login_method" data-provider="github">
* <button data-bind="click: save_login_method.bind('email')">
* Log in using GitHub
* </button>
* </form>
*
* @param {Object} data - Context data
* @param {Event} event - Click event
* @param {string} method - Method id
* @returns {knockout_click}
*/
save_login_method(data, event) {
const elem = event.currentTarget;
save_login_method(method) {
if (window.isSecureContext) {
console.debug("Setting last login method: ", elem.dataset.provider);
cookieStore.set("last-login-method", elem.dataset.provider);
console.debug("Setting last login method:", method);
cookieStore.set("last-login-method", method);
} else {
console.debug("Insecure, not setting last login method:", method);
}
return true;
}

// On top of showing the GitHub modal, show any popups for GitHub providers as
// well. This avoids requiring the popups to always be visible and avoids
// manual removal of the popups.
show_github_modal() {
super.show_github_modal();
const last_method = this.last_method();
if (["github", "githubapp"].includes(last_method)) {
console.debug(last_method, this.popups, this.popups[last_method]);
this.popups[last_method]((popup) => popup("show"));
}
}
}

Registry.add_view(SocialAccountView);
Registry.add_view(LoginView);