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
|
@ -67,13 +67,14 @@ public final class RegistryLockGetAction implements JsonGetAction {
|
||||||
private static final String FULLY_QUALIFIED_DOMAIN_NAME_PARAM = "fullyQualifiedDomainName";
|
private static final String FULLY_QUALIFIED_DOMAIN_NAME_PARAM = "fullyQualifiedDomainName";
|
||||||
private static final String LOCKED_TIME_PARAM = "lockedTime";
|
private static final String LOCKED_TIME_PARAM = "lockedTime";
|
||||||
private static final String LOCKED_BY_PARAM = "lockedBy";
|
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 FluentLogger logger = FluentLogger.forEnclosingClass();
|
||||||
private static final Gson GSON = new Gson();
|
private static final Gson GSON = new Gson();
|
||||||
|
|
||||||
@VisibleForTesting Method method;
|
@VisibleForTesting Method method;
|
||||||
private final Response response;
|
private final Response response;
|
||||||
private final AuthenticatedRegistrarAccessor registrarAccessor;
|
@VisibleForTesting AuthenticatedRegistrarAccessor registrarAccessor;
|
||||||
@VisibleForTesting AuthResult authResult;
|
@VisibleForTesting AuthResult authResult;
|
||||||
@VisibleForTesting Optional<String> paramClientId;
|
@VisibleForTesting Optional<String> paramClientId;
|
||||||
|
|
||||||
|
@ -118,7 +119,7 @@ public final class RegistryLockGetAction implements JsonGetAction {
|
||||||
// Note: admins always have access to the locks page
|
// Note: admins always have access to the locks page
|
||||||
checkArgument(authResult.userAuthInfo().isPresent(), "User auth info must be present");
|
checkArgument(authResult.userAuthInfo().isPresent(), "User auth info must be present");
|
||||||
UserAuthInfo userAuthInfo = authResult.userAuthInfo().get();
|
UserAuthInfo userAuthInfo = authResult.userAuthInfo().get();
|
||||||
boolean isAdmin = userAuthInfo.isUserAdmin();
|
boolean isAdmin = registrarAccessor.isAdmin();
|
||||||
Registrar registrar = getRegistrarAndVerifyLockAccess(clientId, isAdmin);
|
Registrar registrar = getRegistrarAndVerifyLockAccess(clientId, isAdmin);
|
||||||
User user = userAuthInfo.user();
|
User user = userAuthInfo.user();
|
||||||
boolean isRegistryLockAllowed =
|
boolean isRegistryLockAllowed =
|
||||||
|
@ -136,7 +137,7 @@ public final class RegistryLockGetAction implements JsonGetAction {
|
||||||
PARAM_CLIENT_ID,
|
PARAM_CLIENT_ID,
|
||||||
registrar.getClientId(),
|
registrar.getClientId(),
|
||||||
LOCKS_PARAM,
|
LOCKS_PARAM,
|
||||||
getLockedDomains(clientId));
|
getLockedDomains(clientId, isAdmin));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Registrar getRegistrarAndVerifyLockAccess(String clientId, boolean isAdmin)
|
private Registrar getRegistrarAndVerifyLockAccess(String clientId, boolean isAdmin)
|
||||||
|
@ -148,19 +149,22 @@ public final class RegistryLockGetAction implements JsonGetAction {
|
||||||
return registrar;
|
return registrar;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ImmutableList<ImmutableMap<String, ?>> getLockedDomains(String clientId) {
|
private ImmutableList<ImmutableMap<String, ?>> getLockedDomains(
|
||||||
|
String clientId, boolean isAdmin) {
|
||||||
return RegistryLockDao.getLockedDomainsByRegistrarId(clientId).stream()
|
return RegistryLockDao.getLockedDomainsByRegistrarId(clientId).stream()
|
||||||
.map(this::lockToMap)
|
.map(lock -> lockToMap(lock, isAdmin))
|
||||||
.collect(toImmutableList());
|
.collect(toImmutableList());
|
||||||
}
|
}
|
||||||
|
|
||||||
private ImmutableMap<String, ?> lockToMap(RegistryLock lock) {
|
private ImmutableMap<String, ?> lockToMap(RegistryLock lock, boolean isAdmin) {
|
||||||
return ImmutableMap.of(
|
return ImmutableMap.of(
|
||||||
FULLY_QUALIFIED_DOMAIN_NAME_PARAM,
|
FULLY_QUALIFIED_DOMAIN_NAME_PARAM,
|
||||||
lock.getDomainName(),
|
lock.getDomainName(),
|
||||||
LOCKED_TIME_PARAM,
|
LOCKED_TIME_PARAM,
|
||||||
lock.getLockCompletionTimestamp().map(DateTime::toString).orElse(""),
|
lock.getLockCompletionTimestamp().map(DateTime::toString).orElse(""),
|
||||||
LOCKED_BY_PARAM,
|
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";
|
String action = postInput.isLock ? "lock" : "unlock";
|
||||||
return JsonResponseHelper.create(SUCCESS, String.format("Successful %s", action));
|
return JsonResponseHelper.create(SUCCESS, String.format("Successful %s", action));
|
||||||
} catch (Throwable e) {
|
} 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(
|
return JsonResponseHelper.create(
|
||||||
ERROR,
|
ERROR,
|
||||||
Optional.ofNullable(Throwables.getRootCause(e).getMessage()).orElse("Unspecified 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 = {};
|
||||||
|
|
||||||
|
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 = {};
|
registry.json.ote = {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -84,13 +84,6 @@ registry.registrar.AdminSettings.prototype.setupEditor = function(objArgs) {
|
||||||
this.onTldAdd_, false, this);
|
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.
|
* Click handler for OT&E status checking button.
|
||||||
* @param {string} xsrfToken
|
* @param {string} xsrfToken
|
||||||
|
@ -102,8 +95,7 @@ registry.registrar.AdminSettings.prototype.oteStatusCheck_ = function(
|
||||||
goog.net.XhrIo.send('/registrar-ote-status', function(e) {
|
goog.net.XhrIo.send('/registrar-ote-status', function(e) {
|
||||||
var response =
|
var response =
|
||||||
/** @type {!registry.json.ote.OteStatusResponse} */
|
/** @type {!registry.json.ote.OteStatusResponse} */
|
||||||
(e.target.getResponseJson(
|
(e.target.getResponseJson(registry.Resource.PARSER_BREAKER_));
|
||||||
registry.registrar.AdminSettings.PARSER_BREAKER_));
|
|
||||||
var oteResultParent = goog.dom.getRequiredElement('ote-status-area-parent');
|
var oteResultParent = goog.dom.getRequiredElement('ote-status-area-parent');
|
||||||
if (response.status === 'SUCCESS') {
|
if (response.status === 'SUCCESS') {
|
||||||
var results = response.results[0];
|
var results = response.results[0];
|
||||||
|
|
|
@ -25,6 +25,7 @@ goog.require('registry.registrar.AdminSettings');
|
||||||
goog.require('registry.registrar.ContactSettings');
|
goog.require('registry.registrar.ContactSettings');
|
||||||
goog.require('registry.registrar.ContactUs');
|
goog.require('registry.registrar.ContactUs');
|
||||||
goog.require('registry.registrar.Dashboard');
|
goog.require('registry.registrar.Dashboard');
|
||||||
|
goog.require('registry.registrar.RegistryLock');
|
||||||
goog.require('registry.registrar.Resources');
|
goog.require('registry.registrar.Resources');
|
||||||
goog.require('registry.registrar.SecuritySettings');
|
goog.require('registry.registrar.SecuritySettings');
|
||||||
goog.require('registry.registrar.WhoisSettings');
|
goog.require('registry.registrar.WhoisSettings');
|
||||||
|
@ -82,6 +83,10 @@ registry.registrar.Console = function(params) {
|
||||||
this.pageMap['whois-settings'] = registry.registrar.WhoisSettings;
|
this.pageMap['whois-settings'] = registry.registrar.WhoisSettings;
|
||||||
this.pageMap['contact-us'] = registry.registrar.ContactUs;
|
this.pageMap['contact-us'] = registry.registrar.ContactUs;
|
||||||
this.pageMap['resources'] = registry.registrar.Resources;
|
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,
|
// 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
|
// but we also need to remove it here, otherwise it'd still be accessible if
|
||||||
// the user manually puts '#admin-settings' in the URL.
|
// 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_;
|
req['id'] = this.id_;
|
||||||
this.sendXhrIo(goog.json.serialize(req), callback);
|
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>
|
<a href="#security-settings">Security</a>
|
||||||
<li>
|
<li>
|
||||||
<a href="#contact-settings">Contact</a>
|
<a href="#contact-settings">Contact</a>
|
||||||
|
<li>
|
||||||
|
<a href="#registry-lock">Registry lock</a>
|
||||||
{if $isAdmin}
|
{if $isAdmin}
|
||||||
<li>
|
<li>
|
||||||
<a href="#admin-settings">Admin</a>
|
<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 $whoisServerNonNull: $whoisServer ?: 'None' /}
|
||||||
{let $urlNonNull: $url ?: 'None' /}
|
{let $urlNonNull: $url ?: 'None' /}
|
||||||
<form name="item" class="{css('item')} {css('registrar')} {css('kd-settings-pane')}">
|
<form name="item" class="{css('item')} {css('registrar')} {css('kd-settings-pane')}">
|
||||||
<h1>WHOIS Settings</h1>
|
<h1>WHOIS settings</h1>
|
||||||
{if $readonly}
|
{if $readonly}
|
||||||
<p>General registrar information for your WHOIS record. This
|
<p>General registrar information for your WHOIS record. This
|
||||||
information is always visible in WHOIS.
|
information is always visible in WHOIS.
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
package google.registry.ui.server.registrar;
|
package google.registry.ui.server.registrar;
|
||||||
|
|
||||||
import static com.google.common.truth.Truth.assertThat;
|
import static com.google.common.truth.Truth.assertThat;
|
||||||
|
import static google.registry.request.auth.AuthenticatedRegistrarAccessor.Role.ADMIN;
|
||||||
import static google.registry.request.auth.AuthenticatedRegistrarAccessor.Role.OWNER;
|
import static google.registry.request.auth.AuthenticatedRegistrarAccessor.Role.OWNER;
|
||||||
import static google.registry.testing.AppEngineRule.makeRegistrar2;
|
import static google.registry.testing.AppEngineRule.makeRegistrar2;
|
||||||
import static google.registry.testing.AppEngineRule.makeRegistrarContact3;
|
import static google.registry.testing.AppEngineRule.makeRegistrarContact3;
|
||||||
|
@ -156,11 +157,13 @@ public final class RegistryLockGetActionTest {
|
||||||
ImmutableMap.of(
|
ImmutableMap.of(
|
||||||
"fullyQualifiedDomainName", "example.test",
|
"fullyQualifiedDomainName", "example.test",
|
||||||
"lockedTime", "2000-06-08T22:00:00.000Z",
|
"lockedTime", "2000-06-08T22:00:00.000Z",
|
||||||
"lockedBy", "johndoe@theregistrar.com"),
|
"lockedBy", "johndoe@theregistrar.com",
|
||||||
|
"userCanUnlock", true),
|
||||||
ImmutableMap.of(
|
ImmutableMap.of(
|
||||||
"fullyQualifiedDomainName", "adminexample.test",
|
"fullyQualifiedDomainName", "adminexample.test",
|
||||||
"lockedTime", "2000-06-08T22:00:00.001Z",
|
"lockedTime", "2000-06-08T22:00:00.001Z",
|
||||||
"lockedBy", "admin")))));
|
"lockedBy", "admin",
|
||||||
|
"userCanUnlock", false)))));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -218,10 +221,15 @@ public final class RegistryLockGetActionTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testSuccess_lockAllowedForAdmin() throws Exception {
|
public void testSuccess_lockAllowedForAdmin() {
|
||||||
// Locks are allowed for admins even when they're not enabled for the registrar
|
// Locks are allowed for admins even when they're not enabled for the registrar
|
||||||
persistResource(makeRegistrar2().asBuilder().setRegistryLockAllowed(false).build());
|
persistResource(makeRegistrar2().asBuilder().setRegistryLockAllowed(false).build());
|
||||||
authResult = AuthResult.create(AuthLevel.USER, UserAuthInfo.create(user, true));
|
authResult = AuthResult.create(AuthLevel.USER, UserAuthInfo.create(user, true));
|
||||||
|
accessor =
|
||||||
|
AuthenticatedRegistrarAccessor.createForTesting(
|
||||||
|
ImmutableSetMultimap.of(
|
||||||
|
"TheRegistrar", ADMIN,
|
||||||
|
"NewRegistrar", OWNER));
|
||||||
action =
|
action =
|
||||||
new RegistryLockGetAction(
|
new RegistryLockGetAction(
|
||||||
Method.GET, response, accessor, authResult, Optional.of("TheRegistrar"));
|
Method.GET, response, accessor, authResult, Optional.of("TheRegistrar"));
|
||||||
|
|
|
@ -21,18 +21,22 @@ import static google.registry.testing.AppEngineRule.makeRegistrarContact2;
|
||||||
import static google.registry.testing.DatastoreHelper.createTld;
|
import static google.registry.testing.DatastoreHelper.createTld;
|
||||||
import static google.registry.testing.DatastoreHelper.loadRegistrar;
|
import static google.registry.testing.DatastoreHelper.loadRegistrar;
|
||||||
import static google.registry.testing.DatastoreHelper.newDomainBase;
|
import static google.registry.testing.DatastoreHelper.newDomainBase;
|
||||||
|
import static google.registry.testing.DatastoreHelper.persistActiveDomain;
|
||||||
import static google.registry.testing.DatastoreHelper.persistResource;
|
import static google.registry.testing.DatastoreHelper.persistResource;
|
||||||
import static google.registry.util.DateTimeUtils.START_OF_TIME;
|
import static google.registry.util.DateTimeUtils.START_OF_TIME;
|
||||||
|
|
||||||
import com.google.common.collect.ImmutableMap;
|
import com.google.common.collect.ImmutableMap;
|
||||||
import com.googlecode.objectify.ObjectifyFilter;
|
import com.googlecode.objectify.ObjectifyFilter;
|
||||||
|
import google.registry.model.domain.DomainBase;
|
||||||
import google.registry.model.ofy.OfyFilter;
|
import google.registry.model.ofy.OfyFilter;
|
||||||
import google.registry.model.registrar.Registrar.State;
|
import google.registry.model.registrar.Registrar.State;
|
||||||
import google.registry.model.registry.RegistryLockDao;
|
import google.registry.model.registry.RegistryLockDao;
|
||||||
import google.registry.module.frontend.FrontendServlet;
|
import google.registry.module.frontend.FrontendServlet;
|
||||||
import google.registry.schema.domain.RegistryLock;
|
import google.registry.schema.domain.RegistryLock;
|
||||||
import google.registry.server.RegistryTestServer;
|
import google.registry.server.RegistryTestServer;
|
||||||
|
import google.registry.testing.AppEngineRule;
|
||||||
import google.registry.testing.CertificateSamples;
|
import google.registry.testing.CertificateSamples;
|
||||||
|
import java.util.UUID;
|
||||||
import org.junit.Rule;
|
import org.junit.Rule;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.junit.runner.RunWith;
|
import org.junit.runner.RunWith;
|
||||||
|
@ -51,10 +55,11 @@ public class RegistrarConsoleScreenshotTest extends WebDriverTestCase {
|
||||||
route("/registrar", FrontendServlet.class),
|
route("/registrar", FrontendServlet.class),
|
||||||
route("/registrar-ote-status", FrontendServlet.class),
|
route("/registrar-ote-status", FrontendServlet.class),
|
||||||
route("/registrar-settings", FrontendServlet.class),
|
route("/registrar-settings", FrontendServlet.class),
|
||||||
|
route("/registry-lock-get", FrontendServlet.class),
|
||||||
route("/registry-lock-verify", FrontendServlet.class))
|
route("/registry-lock-verify", FrontendServlet.class))
|
||||||
.setFilters(ObjectifyFilter.class, OfyFilter.class)
|
.setFilters(ObjectifyFilter.class, OfyFilter.class)
|
||||||
.setFixtures(BASIC)
|
.setFixtures(BASIC)
|
||||||
.setEmail("Marla.Singer@google.com")
|
.setEmail("Marla.Singer@crr.com")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -407,4 +412,119 @@ public class RegistrarConsoleScreenshotTest extends WebDriverTestCase {
|
||||||
driver.waitForElement(By.id("reg-content"));
|
driver.waitForElement(By.id("reg-content"));
|
||||||
driver.diffPage("page");
|
driver.diffPage("page");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void registryLock_empty() throws Throwable {
|
||||||
|
driver.get(server.getUrl("/registrar?clientId=TheRegistrar#registry-lock"));
|
||||||
|
driver.waitForElement(By.tagName("h2"));
|
||||||
|
driver.diffPage("page");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void registryLock_notAllowed() throws Throwable {
|
||||||
|
server.runInAppEngineEnvironment(
|
||||||
|
() -> {
|
||||||
|
persistResource(makeRegistrar2().asBuilder().setRegistryLockAllowed(false).build());
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
driver.get(server.getUrl("/registrar?clientId=TheRegistrar#registry-lock"));
|
||||||
|
driver.waitForElement(By.tagName("h2"));
|
||||||
|
driver.diffPage("page");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void registryLock_nonEmpty() throws Throwable {
|
||||||
|
server.runInAppEngineEnvironment(
|
||||||
|
() -> {
|
||||||
|
saveRegistryLock();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
driver.get(server.getUrl("/registrar#registry-lock"));
|
||||||
|
driver.waitForElement(By.tagName("h2"));
|
||||||
|
driver.diffPage("page");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void registryLock_nonEmpty_admin() throws Throwable {
|
||||||
|
server.runInAppEngineEnvironment(
|
||||||
|
() -> {
|
||||||
|
createTld("tld");
|
||||||
|
DomainBase domain = persistActiveDomain("example.tld");
|
||||||
|
RegistryLockDao.save(createRegistryLock(domain).asBuilder().isSuperuser(true).build());
|
||||||
|
DomainBase otherDomain = persistActiveDomain("otherexample.tld");
|
||||||
|
RegistryLockDao.save(createRegistryLock(otherDomain));
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
driver.get(server.getUrl("/registrar#registry-lock"));
|
||||||
|
driver.waitForElement(By.tagName("h2"));
|
||||||
|
driver.diffPage("page");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void registryLock_unlockModal() throws Throwable {
|
||||||
|
server.setIsAdmin(true);
|
||||||
|
server.runInAppEngineEnvironment(
|
||||||
|
() -> {
|
||||||
|
saveRegistryLock();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
driver.get(server.getUrl("/registrar#registry-lock"));
|
||||||
|
driver.waitForElement(By.tagName("h2"));
|
||||||
|
driver.findElement(By.id("button-unlock-example.tld")).click();
|
||||||
|
driver.waitForElement(By.className("modal-content"));
|
||||||
|
driver.findElement(By.id("domain-lock-password")).sendKeys("password");
|
||||||
|
driver.diffPage("page");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void registryLock_lockModal() throws Throwable {
|
||||||
|
server.setIsAdmin(true);
|
||||||
|
server.runInAppEngineEnvironment(
|
||||||
|
() -> {
|
||||||
|
createTld("tld");
|
||||||
|
persistActiveDomain("example.tld");
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
driver.get(server.getUrl("/registrar#registry-lock"));
|
||||||
|
driver.waitForElement(By.tagName("h2"));
|
||||||
|
driver.findElement(By.id("button-lock-domain")).click();
|
||||||
|
driver.waitForElement(By.className("modal-content"));
|
||||||
|
driver.findElement(By.id("domain-lock-input-value")).sendKeys("somedomain.tld");
|
||||||
|
driver.findElement(By.id("domain-lock-password")).sendKeys("password");
|
||||||
|
driver.diffPage("page");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void registryLock_notAllowedForUser() throws Throwable {
|
||||||
|
server.runInAppEngineEnvironment(
|
||||||
|
() -> {
|
||||||
|
persistResource(
|
||||||
|
AppEngineRule.makeRegistrarContact3()
|
||||||
|
.asBuilder()
|
||||||
|
.setAllowedToSetRegistryLockPassword(true)
|
||||||
|
.build());
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
driver.get(server.getUrl("/registrar?clientId=TheRegistrar#registry-lock"));
|
||||||
|
driver.waitForElement(By.tagName("h2"));
|
||||||
|
driver.diffPage("page");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveRegistryLock() {
|
||||||
|
createTld("tld");
|
||||||
|
DomainBase domainBase = persistActiveDomain("example.tld");
|
||||||
|
RegistryLockDao.save(createRegistryLock(domainBase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private RegistryLock createRegistryLock(DomainBase domainBase) {
|
||||||
|
return new RegistryLock.Builder()
|
||||||
|
.setVerificationCode(UUID.randomUUID().toString())
|
||||||
|
.isSuperuser(false)
|
||||||
|
.setRegistrarId("TheRegistrar")
|
||||||
|
.setRegistrarPocId("Marla.Singer@crr.com")
|
||||||
|
.setLockCompletionTimestamp(START_OF_TIME)
|
||||||
|
.setDomainName("example.tld")
|
||||||
|
.setRepoId(domainBase.getRepoId())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 90 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 50 KiB |
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 91 KiB |
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 76 KiB |
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 76 KiB |
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 72 KiB |
After Width: | Height: | Size: 49 KiB |
After Width: | Height: | Size: 52 KiB |
After Width: | Height: | Size: 56 KiB |
After Width: | Height: | Size: 54 KiB |
After Width: | Height: | Size: 42 KiB |
After Width: | Height: | Size: 39 KiB |
After Width: | Height: | Size: 55 KiB |
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 51 KiB |
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 69 KiB |
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 86 KiB |
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 94 KiB |
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 94 KiB |
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 94 KiB |
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 92 KiB |
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 45 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 45 KiB |
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 51 KiB |
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 200 KiB |
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 197 KiB |
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 74 KiB |
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 65 KiB |
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 142 KiB |
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 56 KiB |