diff --git a/app/assets/javascripts/admin/models/report.js.es6 b/app/assets/javascripts/admin/models/report.js.es6 index 709dac0a52b..6fc812f349f 100644 --- a/app/assets/javascripts/admin/models/report.js.es6 +++ b/app/assets/javascripts/admin/models/report.js.es6 @@ -276,9 +276,13 @@ const Report = Discourse.Model.extend({ return this._numberLabel(value, opts); } if (type === "date") { - const date = moment(value, "YYYY-MM-DD"); + const date = moment(value); if (date.isValid()) return this._dateLabel(value, date); } + if (type === "precise_date") { + const date = moment(value); + if (date.isValid()) return this._dateLabel(value, date, "LLL"); + } if (type === "text") return this._textLabel(value); return { @@ -377,10 +381,10 @@ const Report = Discourse.Model.extend({ }; }, - _dateLabel(value, date) { + _dateLabel(value, date, format = "LL") { return { value, - formatedValue: value ? date.format("LL") : "—" + formatedValue: value ? date.format(format) : "—" }; }, diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 index 89adcfbb157..4ce0b8f3137 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -8,6 +8,10 @@ export default function() { path: "/dashboard/moderation", resetNamespace: true }); + this.route("admin.dashboardNextSecurity", { + path: "/dashboard/security", + resetNamespace: true + }); }); this.route( diff --git a/app/assets/javascripts/admin/templates/dashboard_next.hbs b/app/assets/javascripts/admin/templates/dashboard_next.hbs index 176f90c22de..e7b1e1c5767 100644 --- a/app/assets/javascripts/admin/templates/dashboard_next.hbs +++ b/app/assets/javascripts/admin/templates/dashboard_next.hbs @@ -21,6 +21,11 @@ {{i18n "admin.dashboard.moderation_tab"}} {{/link-to}} + {{outlet}} diff --git a/app/assets/javascripts/admin/templates/dashboard_next_security.hbs b/app/assets/javascripts/admin/templates/dashboard_next_security.hbs new file mode 100644 index 00000000000..a42e65d58ad --- /dev/null +++ b/app/assets/javascripts/admin/templates/dashboard_next_security.hbs @@ -0,0 +1,15 @@ +
+ {{plugin-outlet name="admin-dashboard-security-top"}} + +
+ {{admin-report + dataSourceName="suspicious_logins" + filters=lastWeekfilters}} + + {{admin-report + dataSourceName="staff_logins" + filters=lastWeekfilters}} + + {{plugin-outlet name="admin-dashboard-security-bottom"}} +
+
diff --git a/app/assets/stylesheets/common/admin/dashboard_next.scss b/app/assets/stylesheets/common/admin/dashboard_next.scss index db101eb4317..1ba8f9a6c83 100644 --- a/app/assets/stylesheets/common/admin/dashboard_next.scss +++ b/app/assets/stylesheets/common/admin/dashboard_next.scss @@ -39,6 +39,10 @@ @include active-navigation-item; } + &.dashboard-next-security .navigation-item.security { + @include active-navigation-item; + } + &.general .navigation-item.general { @include active-navigation-item; } @@ -488,14 +492,8 @@ margin-bottom: 1.5em; } -.dashboard-next-moderation { - .admin-dashboard-moderation-top { - display: grid; - grid-template-columns: repeat(12, 1fr); - grid-column-gap: 1em; - grid-row-gap: 1em; - } - +.dashboard-next-moderation, +.dashboard-next-security { .section-body { margin-bottom: 1em; } @@ -510,6 +508,7 @@ grid-column: span 12; } + .admin-dashboard-security-bottom-outlet, .admin-dashboard-moderation-bottom-outlet { display: grid; grid-template-columns: repeat(12, 1fr); @@ -518,11 +517,16 @@ } } - .admin-report.flags-status { - grid-column: span 12; - } - - .admin-report.post-edits { + .admin-report { grid-column: span 12; } } + +.dashboard-next-moderation { + .admin-dashboard-moderation-top { + display: grid; + grid-template-columns: repeat(12, 1fr); + grid-column-gap: 1em; + grid-row-gap: 1em; + } +} diff --git a/app/controllers/admin/dashboard_next_controller.rb b/app/controllers/admin/dashboard_next_controller.rb index 8da41c075db..41360bd7e57 100644 --- a/app/controllers/admin/dashboard_next_controller.rb +++ b/app/controllers/admin/dashboard_next_controller.rb @@ -12,6 +12,7 @@ class Admin::DashboardNextController < Admin::AdminController end def moderation; end + def security; end def general data = AdminDashboardNextGeneralData.fetch_cached_stats diff --git a/app/models/report.rb b/app/models/report.rb index 09ad16684ec..19b0b70922e 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -1276,6 +1276,64 @@ class Report end end + def self.report_staff_logins(report) + report.modes = [:table] + + report.data = [] + + report.labels = [ + { + type: :user, + properties: { + username: :username, + id: :user_id, + avatar: :avatar_template, + }, + title: I18n.t("reports.staff_logins.labels.user") + }, + { + property: :location, + title: I18n.t("reports.staff_logins.labels.location") + }, + { + property: :created_at, + type: :precise_date, + title: I18n.t("reports.staff_logins.labels.login_at") + } + ] + + sql = <<~SQL + SELECT + t1.created_at created_at, + t1.client_ip client_ip, + u.username username, + u.uploaded_avatar_id uploaded_avatar_id, + u.id user_id + FROM ( + SELECT DISTINCT ON (t.client_ip, t.user_id) t.client_ip, t.user_id, t.created_at + FROM user_auth_token_logs t + WHERE t.user_id IN (#{User.admins.pluck(:id).join(',')}) + AND t.created_at >= :start_date + AND t.created_at <= :end_date + ORDER BY t.client_ip, t.user_id, t.created_at DESC + LIMIT #{report.limit || 20} + ) t1 + JOIN users u ON u.id = t1.user_id + ORDER BY created_at DESC + SQL + + DB.query(sql, start_date: report.start_date, end_date: report.end_date).each do |row| + data = {} + data[:avatar_template] = User.avatar_template(row.username, row.uploaded_avatar_id) + data[:user_id] = row.user_id + data[:username] = row.username + data[:location] = DiscourseIpInfo.get(row.client_ip)[:location] + data[:created_at] = row.created_at + + report.data << data + end + end + def self.report_suspicious_logins(report) report.modes = [:table] diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 33d9dbd4d3f..548fd101945 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2854,6 +2854,7 @@ en: all_reports: "All reports" general_tab: "General" moderation_tab: "Moderation" + security_tab: "Security" disabled: Disabled timeout_error: Sorry, query is taking too long, please pick a shorter interval exception_error: Sorry, an error occurred while executing the query diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 4b1d90efb33..f3eacbe9b65 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1134,6 +1134,12 @@ en: device: Device os: Operating System login_time: Login Time + staff_logins: + title: "Staff logins" + labels: + user: User + location: Location + login_at: Login at dashboard: rails_env_warning: "Your server is running in %{env} mode." diff --git a/config/routes.rb b/config/routes.rb index b16f3330882..c46c943ca65 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -241,6 +241,7 @@ Discourse::Application.routes.draw do get "dashboard" => "dashboard_next#index" get "dashboard/general" => "dashboard_next#general" get "dashboard/moderation" => "dashboard_next#moderation" + get "dashboard/security" => "dashboard_next#security" get "dashboard-old" => "dashboard#index" diff --git a/lib/discourse_ip_info.rb b/lib/discourse_ip_info.rb index cefeaa64b47..343312c6968 100644 --- a/lib/discourse_ip_info.rb +++ b/lib/discourse_ip_info.rb @@ -42,7 +42,7 @@ class DiscourseIpInfo ret[:city] = result.city.name(locale) || result.city.name ret[:latitude] = result.location.latitude ret[:longitude] = result.location.longitude - ret[:location] = [ret[:city], ret[:region], ret[:country]].reject(&:blank?).join(", ") + ret[:location] = ret.values_at(:city, :region, :country).reject(&:blank?).uniq.join(", ") end rescue => e Discourse.warn_exception(e, message: "IP #{ip} could not be looked up in MaxMind GeoLite2-City database.") diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb index b0a43dc712e..aa6d86ae5f5 100644 --- a/spec/models/report_spec.rb +++ b/spec/models/report_spec.rb @@ -995,6 +995,37 @@ describe Report do expect(report.data[2][:username]).to eq("joffrey") end end + end + describe "report_staff_logins" do + let(:joffrey) { Fabricate(:admin, username: "joffrey") } + let(:robin) { Fabricate(:admin, username: "robin") } + let(:james) { Fabricate(:user, username: "james") } + + context "with data" do + it "works" do + freeze_time DateTime.parse('2017-03-01 12:00') + + ip = [81, 2, 69, 142] + + DiscourseIpInfo.open_db(File.join(Rails.root, 'spec', 'fixtures', 'mmdb')) + Resolv::DNS.any_instance.stubs(:getname).with(ip.join(".")).returns("ip-#{ip.join("-")}.example.com") + + UserAuthToken.log(action: "generate", user_id: robin.id, client_ip: ip.join("."), created_at: 1.hour.ago) + UserAuthToken.log(action: "generate", user_id: joffrey.id, client_ip: "1.2.3.4") + UserAuthToken.log(action: "generate", user_id: joffrey.id, client_ip: ip.join("."), created_at: 2.hours.ago) + UserAuthToken.log(action: "generate", user_id: james.id) + + report = Report.find("staff_logins") + + expect(report.data.length).to eq(3) + expect(report.data[0][:username]).to eq("joffrey") + + expect(report.data[1][:username]).to eq("robin") + expect(report.data[1][:location]).to eq("London, England, United Kingdom") + + expect(report.data[2][:username]).to eq("joffrey") + end + end end end