Add Registry Lock UI (#369)

* Add Registry Lock UI

* Responses to CRs, mostly

TODO:
- Figure out wording for the 'not enabled yet' message
- Include the server status change cost in the email, or in the UI?
- Should we show non-completed lock requests in the UI?

* Fix get action test

* Change the not-allowed-for-registrar msg to include support email

* Change the wording on the price

* Move TLD input into the modal, and other changes

- don't log the password
- test to make sure the password shows bullets

* Responses to CR and cleanup

* Format closer to something proper
This commit is contained in:
gbrodman
2020-02-20 18:04:32 -05:00
committed by GitHub
parent ec6157b6ac
commit 8b819a27e7
50 changed files with 548 additions and 22 deletions
@@ -67,13 +67,14 @@ public final class RegistryLockGetAction implements JsonGetAction {
private static final String FULLY_QUALIFIED_DOMAIN_NAME_PARAM = "fullyQualifiedDomainName";
private static final String LOCKED_TIME_PARAM = "lockedTime";
private static final String LOCKED_BY_PARAM = "lockedBy";
private static final String USER_CAN_UNLOCK_PARAM = "userCanUnlock";
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static final Gson GSON = new Gson();
@VisibleForTesting Method method;
private final Response response;
private final AuthenticatedRegistrarAccessor registrarAccessor;
@VisibleForTesting AuthenticatedRegistrarAccessor registrarAccessor;
@VisibleForTesting AuthResult authResult;
@VisibleForTesting Optional<String> paramClientId;
@@ -118,7 +119,7 @@ public final class RegistryLockGetAction implements JsonGetAction {
// Note: admins always have access to the locks page
checkArgument(authResult.userAuthInfo().isPresent(), "User auth info must be present");
UserAuthInfo userAuthInfo = authResult.userAuthInfo().get();
boolean isAdmin = userAuthInfo.isUserAdmin();
boolean isAdmin = registrarAccessor.isAdmin();
Registrar registrar = getRegistrarAndVerifyLockAccess(clientId, isAdmin);
User user = userAuthInfo.user();
boolean isRegistryLockAllowed =
@@ -136,7 +137,7 @@ public final class RegistryLockGetAction implements JsonGetAction {
PARAM_CLIENT_ID,
registrar.getClientId(),
LOCKS_PARAM,
getLockedDomains(clientId));
getLockedDomains(clientId, isAdmin));
}
private Registrar getRegistrarAndVerifyLockAccess(String clientId, boolean isAdmin)
@@ -148,19 +149,22 @@ public final class RegistryLockGetAction implements JsonGetAction {
return registrar;
}
private ImmutableList<ImmutableMap<String, ?>> getLockedDomains(String clientId) {
private ImmutableList<ImmutableMap<String, ?>> getLockedDomains(
String clientId, boolean isAdmin) {
return RegistryLockDao.getLockedDomainsByRegistrarId(clientId).stream()
.map(this::lockToMap)
.map(lock -> lockToMap(lock, isAdmin))
.collect(toImmutableList());
}
private ImmutableMap<String, ?> lockToMap(RegistryLock lock) {
private ImmutableMap<String, ?> lockToMap(RegistryLock lock, boolean isAdmin) {
return ImmutableMap.of(
FULLY_QUALIFIED_DOMAIN_NAME_PARAM,
lock.getDomainName(),
LOCKED_TIME_PARAM,
lock.getLockCompletionTimestamp().map(DateTime::toString).orElse(""),
LOCKED_BY_PARAM,
lock.isSuperuser() ? "admin" : lock.getRegistrarPocId());
lock.isSuperuser() ? "admin" : lock.getRegistrarPocId(),
USER_CAN_UNLOCK_PARAM,
isAdmin || !lock.isSuperuser());
}
}
@@ -151,7 +151,7 @@ public class RegistryLockPostAction implements Runnable, JsonActionRunner.JsonAc
String action = postInput.isLock ? "lock" : "unlock";
return JsonResponseHelper.create(SUCCESS, String.format("Successful %s", action));
} catch (Throwable e) {
logger.atWarning().withCause(e).log("Failed to lock/unlock domain with input %s", input);
logger.atWarning().withCause(e).log("Failed to lock/unlock domain");
return JsonResponseHelper.create(
ERROR,
Optional.ofNullable(Throwables.getRootCause(e).getMessage()).orElse("Unspecified error"));
@@ -0,0 +1,47 @@
/** Registry Lock */
.new-registry-lock #lock-domain-input {
width: 50%
}
.new-registry-lock #lock-domain-submit {
margin-left: 5px;
}
.registry-locks-table {
width: 1000px;
}
.registry-locks-table td {
padding: 0.5em 0;
vertical-align: middle;
}
.lock-confirm-modal {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(0,0,0,0.8);
z-index: 99999;
}
.lock-confirm-modal > div {
width: 400px;
position: relative;
margin: 10% auto;
padding: 5px 20px 13px 20px;
border-radius: 10px;
background: #fff;
}
.lock-confirm-modal .buttons-div {
margin-top: 10px;
display: flex;
justify-content: flex-end;
}
.lock-confirm-modal button {
margin-left: 10px
}
@@ -30,6 +30,37 @@ var registry = {};
*/
registry.json = {};
registry.json.locks = {};
/**
* @typedef {{
* fullyQualifiedDomainName: string,
* lockedTime: string,
* lockedBy: string,
* userCanUnlock: boolean
* }}
*/
registry.json.locks.ExistingLock;
/**
* @typedef {{
* clientId: string,
* email: string,
* details: !Array.<registry.json.locks.ExistingLock>,
* lockEnabledForContact: boolean
* }}
*/
registry.json.locks.ExistingLocksResult;
/**
* @typedef {{
* status: string,
* message: string,
* results: !Array.<registry.json.locks.ExistingLocksResult>
* }}
*/
registry.json.locks.ExistingLocksResponse;
registry.json.ote = {};
/**
@@ -84,13 +84,6 @@ registry.registrar.AdminSettings.prototype.setupEditor = function(objArgs) {
this.onTldAdd_, false, this);
};
/**
* JSON response prefix which prevents evaluation.
* @private {string}
* @const
*/
registry.registrar.AdminSettings.PARSER_BREAKER_ = ')]}\'\n';
/**
* Click handler for OT&E status checking button.
* @param {string} xsrfToken
@@ -102,8 +95,7 @@ registry.registrar.AdminSettings.prototype.oteStatusCheck_ = function(
goog.net.XhrIo.send('/registrar-ote-status', function(e) {
var response =
/** @type {!registry.json.ote.OteStatusResponse} */
(e.target.getResponseJson(
registry.registrar.AdminSettings.PARSER_BREAKER_));
(e.target.getResponseJson(registry.Resource.PARSER_BREAKER_));
var oteResultParent = goog.dom.getRequiredElement('ote-status-area-parent');
if (response.status === 'SUCCESS') {
var results = response.results[0];
@@ -25,6 +25,7 @@ goog.require('registry.registrar.AdminSettings');
goog.require('registry.registrar.ContactSettings');
goog.require('registry.registrar.ContactUs');
goog.require('registry.registrar.Dashboard');
goog.require('registry.registrar.RegistryLock');
goog.require('registry.registrar.Resources');
goog.require('registry.registrar.SecuritySettings');
goog.require('registry.registrar.WhoisSettings');
@@ -82,6 +83,10 @@ registry.registrar.Console = function(params) {
this.pageMap['whois-settings'] = registry.registrar.WhoisSettings;
this.pageMap['contact-us'] = registry.registrar.ContactUs;
this.pageMap['resources'] = registry.registrar.Resources;
// Registry lock is enabled or not per registrar, but since we don't have the registrar object
// accessible here yet, show the link no matter what (the page will show an error message if
// registry lock isn't enabled for this registrar)
this.pageMap['registry-lock'] = registry.registrar.RegistryLock;
// For admin use. The relevant tab is only shown in Console.soy for admins,
// but we also need to remove it here, otherwise it'd still be accessible if
// the user manually puts '#admin-settings' in the URL.
@@ -0,0 +1,179 @@
// Copyright 2020 The Nomulus Authors. All Rights Reserved.
//
// 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.
goog.provide('registry.registrar.RegistryLock');
goog.forwardDeclare('registry.registrar.Console');
goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.dom.classlist');
goog.require('goog.events');
goog.require('goog.events.KeyCodes');
goog.require('goog.events.EventType');
goog.require('goog.json');
goog.require('goog.net.XhrIo');
goog.require('goog.soy');
goog.require('registry.Resource');
goog.require('registry.ResourceComponent');
goog.require('registry.soy.registrar.registrylock');
/**
* Registry Lock page, allowing the user to lock / unlock domains.
* @param {!registry.registrar.Console} console
* @param {!registry.Resource} resource the RESTful resource for the registrar.
* @constructor
* @extends {registry.ResourceComponent}
* @final
*/
registry.registrar.RegistryLock = function(console, resource) {
registry.registrar.RegistryLock.base(
this, 'constructor', console, resource,
registry.soy.registrar.registrylock.settings, false, null);
};
goog.inherits(registry.registrar.RegistryLock, registry.ResourceComponent);
registry.registrar.RegistryLock.prototype.runAfterRender = function(objArgs) {
this.clientId = objArgs.clientId;
this.xsrfToken = objArgs.xsrfToken;
if (objArgs.registryLockAllowed) {
// Load the existing locks and display them in the table
goog.net.XhrIo.send(
'/registry-lock-get?clientId=' + objArgs.clientId, e => this.fillLocksPage_(e));
} else {
goog.soy.renderElement(
goog.dom.getRequiredElement('locks-content'),
registry.soy.registrar.registrylock.lockNotAllowedOnRegistrar,
{supportEmail: objArgs.supportEmail});
}
};
/**
* Removes the lock/unlock-confirmation modal if it exists
* @private
*/
const removeModalIfExists_ = function() {
var modalElement = goog.dom.getElement('lock-confirm-modal');
if (modalElement != null) {
modalElement.parentElement.removeChild(modalElement);
}
}
/**
* Clears the modal and displays the locks content (lock a new domain, existing locks) that was
* retrieved from the server.
* @private
*/
registry.registrar.RegistryLock.prototype.fillLocksPage_ = function(e) {
var response =
/** @type {!registry.json.locks.ExistingLocksResponse} */
(e.target.getResponseJson(registry.Resource.PARSER_BREAKER_));
if (response.status === 'SUCCESS') {
removeModalIfExists_();
var locksDetails = response.results[0]
var locksContentDiv = goog.dom.getRequiredElement('locks-content');
goog.soy.renderElement(
locksContentDiv,
registry.soy.registrar.registrylock.locksContent,
{locks: locksDetails.locks,
email: locksDetails.email,
lockEnabledForContact: locksDetails.lockEnabledForContact});
if (locksDetails.lockEnabledForContact) {
// Listen to the lock-domain 'submit' button click as well as the enter key
var lockButton = goog.dom.getRequiredElement('button-lock-domain');
goog.events.listen(lockButton, goog.events.EventType.CLICK, this.onLockDomain_, false, this);
// For all unlock buttons, listen and perform the unlock action if they're clicked
var unlockButtons = goog.dom.getElementsByClass('domain-unlock-button', locksContentDiv);
unlockButtons.forEach(button =>
goog.events.listen(button, goog.events.EventType.CLICK, this.onUnlockDomain_, false, this));
}
} else {
var errorDiv = goog.dom.getRequiredElement('modal-error-message');
errorDiv.textContent = response.message;
errorDiv.removeAttribute('hidden');
}
}
/**
* Shows the lock/unlock confirmation modal
* @private
*/
registry.registrar.RegistryLock.prototype.showModal_ = function(targetElement, domain, isLock) {
var parentElement = targetElement.parentElement;
// attach the modal to the parent element so focus remains correct if the user closes the modal
var modalElement = goog.soy.renderAsElement(
registry.soy.registrar.registrylock.confirmModal, {domain: domain, isLock: isLock});
parentElement.prepend(modalElement);
goog.dom.getRequiredElement('domain-lock-password').focus();
// delete the modal when the user clicks the cancel button
goog.events.listen(
goog.dom.getRequiredElement('domain-lock-cancel'),
goog.events.EventType.CLICK,
removeModalIfExists_,
false,
this);
goog.events.listen(
goog.dom.getRequiredElement('domain-lock-submit'),
goog.events.EventType.CLICK,
e => this.lockOrUnlockDomain_(isLock, e),
false,
this);
}
/**
* Locks or unlocks the specified domain
* @private
*/
registry.registrar.RegistryLock.prototype.lockOrUnlockDomain_ = function(isLock, e) {
var domain = goog.dom.getRequiredElement('domain-lock-input-value').value;
var password = goog.dom.getRequiredElement('domain-lock-password').value;
goog.net.XhrIo.send('/registry-lock-post',
e => this.fillLocksPage_(e),
'POST',
goog.json.serialize({
'clientId': this.clientId,
'fullyQualifiedDomainName': domain,
'isLock': isLock,
'password': password
}), {
'X-CSRF-Token': this.xsrfToken,
'Content-Type': 'application/json; charset=UTF-8'
});
}
/**
* Click handler for unlocking domains (button click).
* @private
*/
registry.registrar.RegistryLock.prototype.onUnlockDomain_ = function(e) {
// the domain is stored in the button ID if it's the right type of button
var idRegex = /button-unlock-(.*)/
var targetId = e.target.id;
var match = targetId.match(idRegex);
if (match) {
var domain = match[1];
this.showModal_(e.target, domain, false);
}
}
/**
* Click handler for lock-domain button.
* @private
*/
registry.registrar.RegistryLock.prototype.onLockDomain_ = function(e) {
this.showModal_(e.target, null, true);
};
@@ -78,3 +78,9 @@ registry.Resource.prototype.send_ =
req['id'] = this.id_;
this.sendXhrIo(goog.json.serialize(req), callback);
};
/**
* JSON response prefix which prevents evaluation.
* @const
*/
registry.Resource.PARSER_BREAKER_ = ')]}\'\n';
@@ -111,6 +111,8 @@
<a href="#security-settings">Security</a>
<li>
<a href="#contact-settings">Contact</a>
<li>
<a href="#registry-lock">Registry lock</a>
{if $isAdmin}
<li>
<a href="#admin-settings">Admin</a>
@@ -0,0 +1,132 @@
// Copyright 2020 The Nomulus Authors. All Rights Reserved.
//
// 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.
{namespace registry.soy.registrar.registrylock}
/** Registry locks viewing, adding, and removing. */
{template .settings}
<h1>Registry lock</h1>
<br>
<div id="locks-content"></div>
{/template}
{template .locksContent}
{@param email: string}
{@param locks: list<[fullyQualifiedDomainName: string, lockedTime: string, lockedBy: string, userCanUnlock: bool]>}
{@param lockEnabledForContact: bool}
{call .newLock}
{param email: $email /}
{param lockEnabledForContact: $lockEnabledForContact /}
{/call}
{call .existingLocksTable}
{param locks: $locks /}
{param lockEnabledForContact: $lockEnabledForContact /}
{/call}
{/template}
{template .newLock}
{@param email: string}
{@param lockEnabledForContact: bool}
<div class="{css('new-registry-lock')}">
{if $lockEnabledForContact}
<h2>Lock a domain</h2>
<br>
<p>The lock will not take effect until you click the confirmation link that will be emailed to
you at {$email}. When it takes effect, you will be billed the standard server status change
billing cost.</p>
<button id="button-lock-domain"
{if $lockEnabledForContact}
class="{css('kd-button')} {css('kd-button-submit')}"
{else}
class="{css('kd-button')}" disabled
{/if}
>Lock a new domain
</button>
{else}
<h2>You are not permitted to change registry locks.</h2>
{/if}
<br><br>
</div>
{/template}
/** Table that displays existing locks for this registrar. */
{template .existingLocksTable}
{@param locks: list<[fullyQualifiedDomainName: string, lockedTime: string, lockedBy: string, userCanUnlock: bool]>}
{@param lockEnabledForContact: bool}
<h2>Existing locks</h2>
<br>
<table class="{css('registry-locks-table')}">
<tr>
<th><b>Domain name</b></th>
<th><b>Date/time locked</b></th>
<th><b>Locked by</b></th>
<th><b>Actions</b></th>
</tr>
{for $lock in $locks}
<tr class="{css('registry-locks-table-row')}">
<td>{$lock.fullyQualifiedDomainName}</td>
<td>{$lock.lockedTime}</td>
<td>{$lock.lockedBy}</td>
<td>
<button id="button-unlock-{$lock.fullyQualifiedDomainName}"
{if $lockEnabledForContact and $lock.userCanUnlock}
class="domain-unlock-button {css('kd-button')} {css('kd-button-submit')}"
{else}
class="{css('kd-button')}"
disabled
{/if}
>Unlock
</button>
</td>
</tr>
{/for}
</table>
{/template}
/** Modal that confirms that the user wishes to lock/unlock a domain. */
{template .confirmModal}
{@param isLock: bool}
{@param? domain: string|null}
<div id="lock-confirm-modal" class="{css('lock-confirm-modal')}">
<div class="modal-content">
<p>Are you sure you want to {if not $isLock}un{/if}lock the domain {$domain}? We will send
an email to the email address on file to confirm the {if not $isLock}un{/if}lock.</p>
<label for="domain-to-lock">Domain: </label>
<input id="domain-lock-input-value"
{if isNonnull($domain)}
value="{$domain}" disabled
{/if}>
<br>
<label for="domain-lock-password">Registry lock password: </label>
<input type="password" id="domain-lock-password">
<br>
<div id="modal-error-message" hidden class="{css('kd-errormessage')}"></div>
<div class="{css('buttons-div')}">
<button id="domain-lock-cancel" class="{css('kd-button')}">Cancel</button>
<button id="domain-lock-submit"
class="{css('kd-button')} {css('kd-button-submit')}">Submit
</button>
</div>
</div>
</div>
{/template}
/** Content if the registrar is not allowed to use registry lock. */
{template .lockNotAllowedOnRegistrar}
{@param supportEmail: string}
<h2>Sorry, your registrar hasn't enrolled in registry lock yet. To do so, please
contact {$supportEmail}.</h2>
{/template}
@@ -32,7 +32,7 @@
{let $whoisServerNonNull: $whoisServer ?: 'None' /}
{let $urlNonNull: $url ?: 'None' /}
<form name="item" class="{css('item')} {css('registrar')} {css('kd-settings-pane')}">
<h1>WHOIS Settings</h1>
<h1>WHOIS settings</h1>
{if $readonly}
<p>General registrar information for your WHOIS record. This
information is always visible in WHOIS.