TwoFactorEnrollmentPage.java

/*
 * Copyright (C) 2000 - 2026 Silverpeas
 * Licensed under the GNU Affero General Public License, version 3 or later.
 */
package org.silverpeas.mobile.client.pages.connexion;

import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.dom.client.DivElement;
import com.google.gwt.event.dom.client.ClickEvent;
import com.google.gwt.uibinder.client.UiBinder;
import com.google.gwt.uibinder.client.UiField;
import com.google.gwt.uibinder.client.UiHandler;
import com.google.gwt.user.client.DOM;
import com.google.gwt.user.client.ui.*;
import org.silverpeas.mobile.client.common.AuthentificationManager;
import org.silverpeas.mobile.client.common.ServicesLocator;
import org.silverpeas.mobile.client.common.network.rest.RestCallback;
import org.silverpeas.mobile.client.common.network.rest.RestMethod;
import org.silverpeas.mobile.client.common.resources.ResourcesManager;
import org.silverpeas.mobile.client.components.base.PageContent;
import org.silverpeas.mobile.client.components.Snackbar;
import org.silverpeas.mobile.client.resources.ApplicationMessages;
import org.silverpeas.mobile.shared.services.rest.ServiceAuthentication;

/**
 * TOTP enrollment with the same layout as the mobile login and verification pages.
 */
public class TwoFactorEnrollmentPage extends PageContent {

  interface TwoFactorEnrollmentPageUiBinder extends UiBinder<Widget, TwoFactorEnrollmentPage> {}

  private static final TwoFactorEnrollmentPageUiBinder uiBinder =
      GWT.create(TwoFactorEnrollmentPageUiBinder.class);

  @UiField(provided = true)
  protected ApplicationMessages msg;

  @UiField
  DivElement setupSection;
  @UiField
  DivElement recoverySection;
  @UiField
  DivElement version;
  @UiField
  Label secret;
  @UiField
  PasswordTextBox codeField;
  @UiField
  CheckBox trustDevice;
  @UiField
  TextArea recoveryCodes;
  @UiField
  Label status;
  @UiField
  Anchor confirm;
  @UiField
  Anchor copySecret;
  @UiField
  Button copyRecoveryCodes;
  @UiField
  Anchor continueButton;

  private final String login;
  private final String password;
  private final String domainId;
  private RestMethod enrollmentAuthenticationMethod;
  private ServiceAuthentication.EnrollmentResult enrollmentResult;

  public TwoFactorEnrollmentPage(String login, String password, String domainId) {
    this.login = login;
    this.password = password;
    this.domainId = domainId;
    msg = GWT.create(ApplicationMessages.class);
    initWidget(uiBinder.createAndBindUi(this));

    trustDevice.setVisible(Boolean.parseBoolean(
        ResourcesManager.getParam("twoFactorTrustedDeviceEnabled")));
    codeField.getElement().setAttribute("inputmode", "numeric");
    codeField.getElement().setAttribute("pattern", "[0-9]*");
    codeField.getElement().setAttribute("autocomplete", "one-time-code");
    codeField.getElement().setAttribute("autocapitalize", "none");
    codeField.getElement().setAttribute("autocorrect", "off");
    codeField.getElement().setAttribute("spellcheck", "off");
    codeField.getElement().setAttribute("placeholder", msg.codeLabel().asString());

    version.setId("version");
    version.setInnerText(msg.version() + " " + ResourcesManager.getVersion());
    Scheduler.get().scheduleDeferred(() ->
        DOM.getElementById("page-login-title")
            .setInnerHTML(ResourcesManager.getLabel("login.title")));
    loadEnrollment();
  }

  private void loadEnrollment() {
    status.setText(msg.enrollmentLoading());
    ServicesLocator.getRestServiceAuthentication(login, password, domainId)
        .startEnrollment(new RestCallback<ServiceAuthentication.EnrollmentSetup>() {
          @Override
          public void onFailure(RestMethod method, Throwable error) {
            status.setText(msg.enrollmentStartError());
          }

          @Override
          public void onSuccess(RestMethod method, ServiceAuthentication.EnrollmentSetup setup) {
            secret.setText(setup.secret);
            status.setText("");
          }
        });
  }

  @UiHandler("copySecret")
  void copyEnrollmentSecret(ClickEvent event) {
    if (secret.getText().isEmpty()) {
      return;
    }
    copyToClipboard(secret.getText(), false);
  }

  @UiHandler("copyRecoveryCodes")
  void copyRecoveryCodes(ClickEvent event) {
    event.preventDefault();
    if (!recoveryCodes.getText().isEmpty()) {
      copyToClipboard(recoveryCodes.getText(), true);
    }
  }

  private void clipboardResult(boolean copied, boolean recovery) {
    if (copied) {
      status.setText("");
      Snackbar.show(recovery ? msg.recoveryCodesCopied() : msg.enrollmentCopied(),
          2500, Snackbar.INFO);
    } else {
      status.setText(recovery ? msg.recoveryCodesCopyError() : msg.enrollmentCopyError());
    }
  }

  /**
   * Uses the asynchronous Clipboard API when available (secure contexts),
   * with a selection-based fallback for older mobile WebViews.
   */
  private native void copyToClipboard(String value, boolean recovery) /*-{
    var page = this;
    function result(ok) {
      page.@org.silverpeas.mobile.client.pages.connexion.TwoFactorEnrollmentPage::clipboardResult(ZZ)(ok, recovery);
    }
    function fallback() {
      var field = $doc.createElement("textarea");
      field.value = value;
      field.setAttribute("readonly", "");
      field.style.position = "fixed";
      field.style.opacity = "0";
      $doc.body.appendChild(field);
      field.focus();
      field.select();
      var ok = false;
      try {
        ok = $doc.execCommand("copy");
      } catch (ignored) {
      }
      $doc.body.removeChild(field);
      result(ok);
    }
    if ($wnd.navigator.clipboard && $wnd.navigator.clipboard.writeText) {
      $wnd.navigator.clipboard.writeText(value).then(function() {
        result(true);
      }, fallback);
    } else {
      fallback();
    }
  }-*/;

  @UiHandler("confirm")
  void confirmEnrollment(ClickEvent event) {
    String value = codeField.getText().trim();
    if (!value.matches("[0-9]{6}")) {
      codeField.getElement().getStyle().setBackgroundColor("#ec9c01");
      status.setText(msg.enrollmentInvalidCode());
      return;
    }
    codeField.getElement().getStyle().clearBackgroundColor();
    confirm.setVisible(false);
    status.setText(msg.enrollmentVerifying());
    ServicesLocator.getRestServiceAuthentication(login, password, domainId)
        .confirmEnrollment(value, trustDevice.getValue(),
            new RestCallback<ServiceAuthentication.EnrollmentResult>() {
              @Override
              public void onFailure(RestMethod method, Throwable error) {
                confirm.setVisible(true);
                status.setText(msg.enrollmentVerifyError());
              }

              @Override
              public void onSuccess(RestMethod method,
                  ServiceAuthentication.EnrollmentResult result) {
                enrollmentAuthenticationMethod = method;
                enrollmentResult = result;
                recoveryCodes.setText(String.join("\n", result.recoveryCodes));
                setupSection.getStyle().setProperty("display", "none");
                recoverySection.getStyle().clearDisplay();
                status.setText("");
              }
            });
  }

  @UiHandler("continueButton")
  void continueAfterRecoveryCodes(ClickEvent event) {
    if (enrollmentResult != null) {
      AuthentificationManager.getInstance().completeEnrollment(
          login, password, domainId, enrollmentAuthenticationMethod, enrollmentResult.profile);
    }
  }
}