UX: refactor IP lookup using DMenu to improve layout and positioning (#30374)

This commit is contained in:
Kris 2024-12-19 14:49:36 -05:00 committed by GitHub
parent 6873962572
commit 2a3f0f3bef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 363 additions and 274 deletions

View File

@ -0,0 +1,266 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DButton from "discourse/components/d-button";
import avatar from "discourse/helpers/avatar";
import loadingSpinner from "discourse/helpers/loading-spinner";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { clipboardCopy } from "discourse/lib/utilities";
import { i18n } from "discourse-i18n";
import AdminUser from "admin/models/admin-user";
import DMenu from "float-kit/components/d-menu";
export default class IpLookup extends Component {
@service dialog;
@service site;
@service toasts;
@tracked location;
@tracked otherAccounts;
@tracked loading = false;
@tracked otherAccountsLoading = false;
@tracked totalOthersWithSameIP;
get otherAccountsToDelete() {
const otherAccountsLength = this.otherAccounts?.length || 0;
const totalOthers = this.totalOthersWithSameIP || 0;
// can only delete up to 50 accounts at a time
const total = Math.min(50, totalOthers);
const visible = Math.min(50, otherAccountsLength);
return Math.max(visible, total);
}
@action
async lookup() {
this.loading = true;
try {
if (!this.location && this.args.ip) {
const loc = await ajax("/admin/users/ip-info", {
data: { ip: this.args.ip },
});
this.location = loc;
}
if (!this.otherAccounts && this.args.ip) {
this.otherAccountsLoading = true;
const data = {
ip: this.args.ip,
exclude: this.args.userId,
order: "trust_level DESC",
};
const result = await ajax("/admin/users/total-others-with-same-ip", {
data,
});
this.totalOthersWithSameIP = result.total;
const users = await AdminUser.findAll("active", data);
this.otherAccounts = users;
this.otherAccountsLoading = false;
}
} catch (error) {
popupAjaxError(error);
} finally {
this.loading = false;
}
}
@action
async copy() {
const { location } = this;
let text = `IP: ${this.args.ip}`;
if (location) {
if (location.hostname) {
text += "\n" + `${i18n("ip_lookup.hostname")}: ${location.hostname}`;
}
text += "\n" + i18n("ip_lookup.location");
text += location.location
? `: ${location.location}`
: `: ${i18n("ip_lookup.location_not_found")}`;
if (location.organization) {
text +=
"\n" + `${i18n("ip_lookup.organisation")}: ${location.organization}`;
}
}
try {
await clipboardCopy(text.trim());
this.toasts.success({
duration: 3000,
data: {
message: i18n("ip_lookup.copied"),
},
});
} catch (err) {
popupAjaxError(err);
}
}
@action
deleteOtherAccounts() {
this.dialog.yesNoConfirm({
message: i18n("ip_lookup.confirm_delete_other_accounts"),
didConfirm: async () => {
// reset state
this.otherAccounts = null;
this.otherAccountsLoading = true;
this.totalOthersWithSameIP = null;
try {
await ajax("/admin/users/delete-others-with-same-ip.json", {
type: "DELETE",
data: {
ip: this.args.ip,
exclude: this.args.userId,
order: "trust_level DESC",
},
});
} catch (err) {
popupAjaxError(err);
}
},
});
}
@action
onRegisterApi(api) {
this.dMenu = api;
}
@action
close() {
this.dMenu.close();
}
<template>
<DMenu
@identifier="ip-lookup"
@label={{i18n "admin.user.ip_lookup"}}
@icon="globe"
@onShow={{this.lookup}}
@modalForMobile={{true}}
@onRegisterApi={{this.onRegisterApi}}
@isLoading={{this.loading}}
>
<:content>
<div class="location-box">
<div class="location-box__content">
<div class="title">
{{i18n "ip_lookup.title"}}
<div class="location-box__controls">
<DButton
@action={{this.copy}}
@icon="copy"
class="btn-transparent"
/>
{{#if this.site.mobileView}}
<DButton
@action={{this.close}}
@icon="xmark"
class="btn-transparent"
/>
{{/if}}
</div>
</div>
<dl>
{{#if this.location}}
{{#if this.location.hostname}}
<dt>{{i18n "ip_lookup.hostname"}}</dt>
<dd>{{this.location.hostname}}</dd>
{{/if}}
<dt>{{i18n "ip_lookup.location"}}</dt>
<dd>
{{#if this.location.location}}
<a
href="https://maps.google.com/maps?q={{this.location.latitude}},{{this.location.longitude}}"
rel="noopener noreferrer"
target="_blank"
>
{{this.location.location}}
</a>
{{else}}
{{i18n "ip_lookup.location_not_found"}}
{{/if}}
</dd>
{{#if this.location.organization}}
<dt>{{i18n "ip_lookup.organisation"}}</dt>
<dd>{{this.location.organization}}</dd>
{{/if}}
{{else}}
{{loadingSpinner size="small"}}
{{/if}}
<dt class="other-accounts">
{{i18n "ip_lookup.other_accounts"}}
<span class="count">{{this.totalOthersWithSameIP}}</span>
{{#if this.otherAccounts}}
<DButton
@action={{this.deleteOtherAccounts}}
@icon="triangle-exclamation"
@translatedLabel={{i18n
"ip_lookup.delete_other_accounts"
count=this.otherAccountsToDelete
}}
class="btn-danger pull-right"
/>
{{/if}}
</dt>
<ConditionalLoadingSpinner
@size="small"
@condition={{this.otherAccountsLoading}}
>
{{#if this.otherAccounts}}
<dd class="other-accounts">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{{i18n "ip_lookup.username"}}</th>
<th>{{i18n "ip_lookup.trust_level"}}</th>
<th>{{i18n "ip_lookup.read_time"}}</th>
<th>{{i18n "ip_lookup.topics_entered"}}</th>
<th>{{i18n "ip_lookup.post_count"}}</th>
</tr>
</thead>
<tbody>
{{#each this.otherAccounts as |account|}}
<tr>
<td class="user">
<LinkTo @route="adminUser" @model={{account}}>
{{avatar account imageSize="tiny"}}
<span>{{account.username}}</span>
</LinkTo>
</td>
<td>{{account.trustLevel.id}}</td>
<td>{{account.time_read}}</td>
<td>{{account.topics_entered}}</td>
<td>{{account.post_count}}</td>
</tr>
{{/each}}
</tbody>
</table>
</dd>
{{/if}}
</ConditionalLoadingSpinner>
</dl>
<div class="powered-by">{{htmlSafe
(i18n "ip_lookup.powered_by")
}}</div>
</div>
</div>
</:content>
</DMenu>
</template>
}

View File

@ -1,114 +0,0 @@
{{#if this.ip}}
<DButton
@action={{this.lookup}}
@icon="globe"
@label="admin.user.ip_lookup"
class="btn-default"
/>
{{/if}}
{{#if this.show}}
<div class="location-box">
<a href class="close pull-right" {{on "click" this.hide}}>{{d-icon
"xmark"
}}</a>
{{#if this.copied}}
<DButton
@icon="copy"
@label="ip_lookup.copied"
class="btn-hover pull-right"
/>
{{else}}
<DButton @action={{this.copy}} @icon="copy" class="pull-right no-text" />
{{/if}}
<h4>{{i18n "ip_lookup.title"}}</h4>
<p class="powered-by">{{html-safe (i18n "ip_lookup.powered_by")}}</p>
<dl>
{{#if this.location}}
{{#if this.location.hostname}}
<dt>{{i18n "ip_lookup.hostname"}}</dt>
<dd>{{this.location.hostname}}</dd>
{{/if}}
<dt>{{i18n "ip_lookup.location"}}</dt>
<dd>
{{#if this.location.location}}
<a
href="https://maps.google.com/maps?q={{this.location.latitude}},{{this.location.longitude}}"
rel="noopener noreferrer"
target="_blank"
>
{{this.location.location}}
</a>
{{else}}
{{i18n "ip_lookup.location_not_found"}}
{{/if}}
</dd>
{{#if this.location.organization}}
<dt>{{i18n "ip_lookup.organisation"}}</dt>
<dd>{{this.location.organization}}</dd>
{{/if}}
{{else}}
{{loading-spinner size="small"}}
{{/if}}
<dt>
{{i18n "ip_lookup.other_accounts"}}
<strong>{{this.totalOthersWithSameIP}}</strong>
{{#if this.other_accounts.length}}
<DButton
@action={{this.deleteOtherAccounts}}
@icon="triangle-exclamation"
@translatedLabel={{i18n
"ip_lookup.delete_other_accounts"
count=this.otherAccountsToDelete
}}
class="btn-danger pull-right"
/>
{{/if}}
</dt>
<ConditionalLoadingSpinner
@size="small"
@condition={{this.otherAccountsLoading}}
>
{{#if this.other_accounts.length}}
<dd class="other-accounts">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{{i18n "ip_lookup.username"}}</th>
<th>{{i18n "ip_lookup.trust_level"}}</th>
<th>{{i18n "ip_lookup.read_time"}}</th>
<th>{{i18n "ip_lookup.topics_entered"}}</th>
<th>{{i18n "ip_lookup.post_count"}}</th>
</tr>
</thead>
<tbody>
{{#each this.other_accounts as |a|}}
<tr>
<td>
<LinkTo @route="adminUser" @model={{a}}>
{{avatar
a
usernamePath="user.username"
imageSize="small"
}}
&nbsp;
<span>{{a.username}}</span>
</LinkTo>
</td>
<td>{{a.trustLevel.id}}</td>
<td>{{a.time_read}}</td>
<td>{{a.topics_entered}}</td>
<td>{{a.post_count}}</td>
</tr>
{{/each}}
</tbody>
</table>
</dd>
{{/if}}
</ConditionalLoadingSpinner>
</dl>
</div>
{{/if}}

View File

@ -1,120 +0,0 @@
import Component from "@ember/component";
import EmberObject, { action } from "@ember/object";
import { service } from "@ember/service";
import { classNames } from "@ember-decorators/component";
import $ from "jquery";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import copyText from "discourse/lib/copy-text";
import discourseLater from "discourse-common/lib/later";
import discourseComputed from "discourse-common/utils/decorators";
import { i18n } from "discourse-i18n";
import AdminUser from "admin/models/admin-user";
@classNames("ip-lookup")
export default class IpLookup extends Component {
@service dialog;
@discourseComputed("other_accounts.length", "totalOthersWithSameIP")
otherAccountsToDelete(otherAccountsLength, totalOthersWithSameIP) {
// can only delete up to 50 accounts at a time
const total = Math.min(50, totalOthersWithSameIP || 0);
const visible = Math.min(50, otherAccountsLength || 0);
return Math.max(visible, total);
}
@action
hide(event) {
event?.preventDefault();
this.set("show", false);
}
@action
lookup() {
this.set("show", true);
if (!this.location) {
ajax("/admin/users/ip-info", {
data: { ip: this.ip },
}).then((location) => this.set("location", EmberObject.create(location)));
}
if (!this.other_accounts) {
this.set("otherAccountsLoading", true);
const data = {
ip: this.ip,
exclude: this.userId,
order: "trust_level DESC",
};
ajax("/admin/users/total-others-with-same-ip", {
data,
}).then((result) => this.set("totalOthersWithSameIP", result.total));
AdminUser.findAll("active", data).then((users) => {
this.setProperties({
other_accounts: users,
otherAccountsLoading: false,
});
});
}
}
@action
copy() {
let text = `IP: ${this.ip}\n`;
const location = this.location;
if (location) {
if (location.hostname) {
text += `${i18n("ip_lookup.hostname")}: ${location.hostname}\n`;
}
text += i18n("ip_lookup.location");
if (location.location) {
text += `: ${location.location}\n`;
} else {
text += `: ${i18n("ip_lookup.location_not_found")}\n`;
}
if (location.organization) {
text += i18n("ip_lookup.organisation");
text += `: ${location.organization}\n`;
}
}
const $copyRange = $('<p id="copy-range"></p>');
$copyRange.html(text.trim().replace(/\n/g, "<br>"));
$(document.body).append($copyRange);
if (copyText(text, $copyRange[0])) {
this.set("copied", true);
discourseLater(() => this.set("copied", false), 2000);
}
$copyRange.remove();
}
@action
deleteOtherAccounts() {
this.dialog.yesNoConfirm({
message: i18n("ip_lookup.confirm_delete_other_accounts"),
didConfirm: () => {
this.setProperties({
other_accounts: null,
otherAccountsLoading: true,
totalOthersWithSameIP: null,
});
ajax("/admin/users/delete-others-with-same-ip.json", {
type: "DELETE",
data: {
ip: this.ip,
exclude: this.userId,
order: "trust_level DESC",
},
})
.catch(popupAjaxError)
.finally(this.send("lookup"));
},
});
}
}

View File

@ -193,7 +193,9 @@
<div class="value">{{this.model.ip_address}}</div>
<div class="controls">
{{#if this.currentUser.staff}}
<IpLookup @ip={{this.model.ip_address}} @userId={{this.model.id}} />
{{#if this.model.ip_address}}
<IpLookup @ip={{this.model.ip_address}} @userId={{this.model.id}} />
{{/if}}
{{/if}}
</div>
</div>
@ -203,10 +205,12 @@
<div class="value">{{this.model.registration_ip_address}}</div>
<div class="controls">
{{#if this.currentUser.staff}}
<IpLookup
@ip={{this.model.registration_ip_address}}
@userId={{this.model.id}}
/>
{{#if this.model.registration_ip_address}}
<IpLookup
@ip={{this.model.registration_ip_address}}
@userId={{this.model.id}}
/>
{{/if}}
{{/if}}
</div>
</div>

View File

@ -97,6 +97,7 @@ export default class DMenu extends Component {
@translatedLabel={{@label}}
@translatedTitle={{@title}}
@disabled={{@disabled}}
@isLoading={{@isLoading}}
aria-expanded={{if this.menuInstance.expanded "true" "false"}}
{{on "keydown" this.forwardTabToContent}}
...attributes

View File

@ -425,37 +425,96 @@ $mobile-breakpoint: 700px;
}
}
.ip-lookup {
position: relative;
display: inline-block;
.ip-lookup-content {
.location-box {
position: absolute;
width: 460px;
right: 0;
z-index: z("dropdown");
box-shadow: var(--shadow-card);
margin-top: -2px;
background-color: var(--secondary);
padding: 12px 12px 5px;
padding: 1em;
max-width: 100%;
box-sizing: border-box;
.title {
font-weight: bold;
font-size: var(--font-up-1);
display: flex;
align-items: center;
}
&__controls {
display: flex;
margin-left: auto;
align-items: center;
}
.powered-by {
font-size: var(--font-down-1);
position: absolute;
bottom: -10px;
left: 10px;
color: var(--primary-high);
}
.other-accounts {
margin: 5px 0 0;
max-height: 200px;
overflow: auto;
width: 455px;
ul {
margin: 0;
.loading-container {
max-width: 100%;
}
dl {
margin-bottom: 0;
}
dt {
font-weight: bold;
&.other-accounts {
display: flex;
align-items: center;
font-weight: normal;
font-size: var(--font-down-1);
.btn {
margin-left: auto;
}
.count {
font-weight: bold;
margin-left: 0.25em;
color: var(--danger);
}
}
li {
list-style: none;
}
tr td:first-of-type {
width: 130px;
}
dd {
margin: 0.25em 0 1em;
&.other-accounts {
margin: 1em 0 0 0;
max-height: 13em;
overflow: auto;
font-size: var(--font-down-1);
padding-right: 0.25em;
thead {
position: sticky;
top: 0;
line-height: 1.2;
background: var(--secondary);
th {
padding: 0.25em;
}
}
ul {
margin: 0;
}
li {
list-style: none;
}
td {
padding: 0.25em 0.25em 0.25em 0;
}
td.user {
white-space: nowrap;
img {
width: 1.25em;
height: 1.25em;
}
}
}
}
}

View File

@ -210,11 +210,4 @@
.associations button {
margin: 5px 5px 0 0;
}
.ip-lookup {
display: block;
.location-box {
left: 0;
}
}
}

View File

@ -1207,10 +1207,10 @@ en:
one: "Delete %{count}"
other: "Delete %{count}"
username: "username"
trust_level: "TL"
trust_level: "trust level"
read_time: "read time"
topics_entered: "topics entered"
post_count: "# posts"
post_count: "posts"
confirm_delete_other_accounts: "Are you sure you want to delete these accounts?"
powered_by: "using <a href='https://maxmind.com'>MaxMindDB</a>"
copied: "copied"