FEATURE: Show "Recently used devices" in user preferences (#6335)

* FEATURE: Added MaxMindDb to resolve IP information.

* FEATURE: Added browser detection based on user agent.

* FEATURE: Added recently used devices in user preferences.

* DEV: Added acceptance test for recently used devices.

* UX: Do not show 'Show more' button if there aren't more tokens.

* DEV: Fix unit tests.

* DEV: Make changes after code review.

* Add more detailed unit tests.

* Improve logging messages.

* Minor coding style fixes.

* DEV: Use DropdownSelectBoxComponent and run Prettier.

* DEV: Fix unit tests.
This commit is contained in:
Bianca Nenciu 2018-10-09 17:21:41 +03:00 committed by Régis Hanol
parent 1fb1f4c790
commit 1d26a473e7
28 changed files with 648 additions and 117 deletions

View File

@ -194,3 +194,4 @@ end
gem 'webpush', require: false gem 'webpush', require: false
gem 'colored2', require: false gem 'colored2', require: false
gem 'maxminddb'

View File

@ -191,6 +191,7 @@ GEM
lru_redux (1.1.0) lru_redux (1.1.0)
mail (2.7.1.rc1) mail (2.7.1.rc1)
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
maxminddb (0.1.21)
memory_profiler (0.9.12) memory_profiler (0.9.12)
message_bus (2.1.5) message_bus (2.1.5)
rack (>= 1.1.3) rack (>= 1.1.3)
@ -488,6 +489,7 @@ DEPENDENCIES
logster logster
lru_redux lru_redux
mail (= 2.7.1.rc1) mail (= 2.7.1.rc1)
maxminddb
memory_profiler memory_profiler
message_bus message_bus
mini_mime mini_mime

View File

@ -0,0 +1,40 @@
import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box";
export default DropdownSelectBoxComponent.extend({
classNames: ["auth-token-dropdown"],
headerIcon: "wrench",
allowInitialValueMutation: false,
showFullTitle: false,
computeContent() {
const content = [
{
id: "notYou",
icon: "user-times",
name: I18n.t("user.auth_tokens.not_you"),
description: ""
},
{
id: "logOut",
icon: "sign-out",
name: I18n.t("user.log_out"),
description: ""
}
];
return content;
},
actions: {
onSelect(id) {
switch (id) {
case "notYou":
this.sendAction("showToken", this.get("token"));
break;
case "logOut":
this.sendAction("revokeAuthToken", this.get("token"));
break;
}
}
}
});

View File

@ -0,0 +1,41 @@
import ModalFunctionality from "discourse/mixins/modal-functionality";
import { ajax } from "discourse/lib/ajax";
import { userPath } from "discourse/lib/url";
export default Ember.Controller.extend(ModalFunctionality, {
expanded: false,
onShow() {
ajax(
userPath(`${this.get("currentUser.username_lower")}/activity.json`)
).then(posts => {
if (posts.length > 0) {
this.set("latest_post", posts[0]);
}
});
},
actions: {
toggleExpanded() {
this.set("expanded", !this.get("expanded"));
},
highlightSecure() {
this.send("closeModal");
Ember.run.next(() => {
const $prefPasswordDiv = $(".pref-password");
$prefPasswordDiv.addClass("highlighted");
$prefPasswordDiv.on("animationend", () =>
$prefPasswordDiv.removeClass("highlighted")
);
window.scrollTo({
top: $prefPasswordDiv.offset().top,
behavior: "smooth"
});
});
}
}
});

View File

@ -9,6 +9,9 @@ import { findAll } from "discourse/models/login-method";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { userPath } from "discourse/lib/url"; import { userPath } from "discourse/lib/url";
// Number of tokens shown by default.
const DEFAULT_AUTH_TOKENS_COUNT = 2;
export default Ember.Controller.extend( export default Ember.Controller.extend(
CanCheckEmails, CanCheckEmails,
PreferencesTabController, PreferencesTabController,
@ -23,6 +26,8 @@ export default Ember.Controller.extend(
passwordProgress: null, passwordProgress: null,
showAllAuthTokens: false,
cannotDeleteAccount: Em.computed.not("currentUser.can_delete_account"), cannotDeleteAccount: Em.computed.not("currentUser.can_delete_account"),
deleteDisabled: Em.computed.or( deleteDisabled: Em.computed.or(
"model.isSaving", "model.isSaving",
@ -99,6 +104,22 @@ export default Ember.Controller.extend(
); );
}, },
@computed("showAllAuthTokens", "model.user_auth_tokens")
authTokens(showAllAuthTokens, tokens) {
tokens.sort(
(a, b) => (a.is_active ? -1 : b.is_active ? 1 : a.seen_at < b.seen_at)
);
return showAllAuthTokens
? tokens
: tokens.slice(0, DEFAULT_AUTH_TOKENS_COUNT);
},
@computed("model.user_auth_tokens")
canShowAllAuthTokens(tokens) {
return tokens.length > DEFAULT_AUTH_TOKENS_COUNT;
},
actions: { actions: {
save() { save() {
this.set("saved", false); this.set("saved", false);
@ -200,19 +221,26 @@ export default Ember.Controller.extend(
}); });
}, },
toggleToken(token) { toggleShowAllAuthTokens() {
Ember.set(token, "visible", !token.visible); this.set("showAllAuthTokens", !this.get("showAllAuthTokens"));
}, },
revokeAuthToken() { revokeAuthToken(token) {
ajax( ajax(
userPath( userPath(
`${this.get("model.username_lower")}/preferences/revoke-auth-token` `${this.get("model.username_lower")}/preferences/revoke-auth-token`
), ),
{ type: "POST" } {
type: "POST",
data: token ? { token_id: token.id } : {}
}
); );
}, },
showToken(token) {
showModal("auth-token", { model: token });
},
connectAccount(method) { connectAccount(method) {
method.doLogin(); method.doLogin();
} }

View File

@ -0,0 +1,33 @@
{{#d-modal-body title="user.auth_tokens.was_this_you"}}
<div>
<p>{{i18n 'user.auth_tokens.was_this_you_description'}}</p>
<p>{{{i18n 'user.second_factor.extended_description'}}}</p>
</div>
<div>
<h3>{{i18n 'user.auth_tokens.details'}}</h3>
<p>{{d-icon "clock-o"}} {{format-date model.seen_at}}</p>
<p>{{d-icon "map-marker"}} {{model.location}}</p>
<p>{{d-icon model.icon}} {{i18n "user.auth_tokens.browser_and_device" browser=model.browser device=model.device}}</p>
</div>
{{#if latest_post}}
<div>
<h3>
{{i18n 'user.auth_tokens.latest_post'}}
<a {{action "toggleExpanded"}}>{{d-icon (if expanded "caret-up" "caret-down")}}</a>
</h3>
{{#if expanded}}
<blockquote>{{{latest_post.cooked}}}</blockquote>
{{else}}
<blockquote>{{{latest_post.excerpt}}}</blockquote>
{{/if}}
</div>
{{/if}}
{{/d-modal-body}}
<div class="modal-footer">
{{d-button class="btn btn-primary" icon="lock" label="user.auth_tokens.secure_account" action="highlightSecure"}}
{{d-modal-cancel close=(action "closeModal")}}
</div>

View File

@ -51,6 +51,48 @@
</div> </div>
{{/if}} {{/if}}
{{#if canCheckEmails}}
<div class="control-group pref-auth-tokens">
<label class="control-label">{{i18n 'user.auth_tokens.title'}}</label>
<div class="auth-tokens">
{{#each authTokens as |token|}}
<div class="row auth-token">
<div class="auth-token-icon">{{d-icon token.icon}}</div>
{{#unless token.is_active}}
{{auth-token-dropdown token=token
revokeAuthToken=(action "revokeAuthToken")
showToken=(action "showToken")}}
{{/unless}}
<div class="auth-token-first">
<span class="auth-token-device">{{token.device}}</span> &ndash; <span title="{{i18n "user.auth_tokens.ip"}}: {{token.client_ip}}">{{token.location}}</span>
</div>
<div class="auth-token-second">
{{token.browser}} |
{{#if token.is_active}}
<span class="active">{{i18n 'user.auth_tokens.active'}}</span>
{{else}}
{{format-date token.seen_at}}
{{/if}}
</div>
</div>
{{/each}}
</div>
{{#if canShowAllAuthTokens}}
<a {{action "toggleShowAllAuthTokens"}}>
{{#if showAllAuthTokens}}
{{d-icon "caret-up"}} {{i18n 'user.auth_tokens.show_few'}}
{{else}}
{{d-icon "caret-down"}} {{i18n 'user.auth_tokens.show_all' count=model.user_auth_tokens.length}}
{{/if}}
</a>
{{/if}}
<a {{action "revokeAuthToken"}} class="pull-right text-danger">{{d-icon "sign-out"}} {{i18n 'user.auth_tokens.log_out_all'}}</a>
</div>
{{/if}}
{{#if canChangePassword}} {{#if canChangePassword}}
<div class="control-group pref-password"> <div class="control-group pref-password">
<label class="control-label">{{i18n 'user.password.title'}}</label> <label class="control-label">{{i18n 'user.password.title'}}</label>

View File

@ -538,6 +538,11 @@ select {
text-align: center; text-align: center;
} }
} }
&.highlighted {
animation: background-fade-highlight 2.5s ease-out;
background-color: dark-light-choose($highlight-low, $highlight);
}
} }
.control-label { .control-label {
@ -609,49 +614,57 @@ select {
} }
.pref-auth-tokens { .pref-auth-tokens {
.control-label {
display: inline-block;
}
.row { .row {
border-bottom: 1px solid #ddd;
margin: 5px 0px; margin: 5px 0px;
} padding-bottom: 5px;
.muted { &:last-child {
color: $primary-medium; border-bottom: 0;
}
.perf-auth-token {
background-color: $primary-very-low;
color: $primary;
display: block;
padding: 5px;
margin-bottom: 10px;
}
.auth-token-summary {
padding: 0px 10px;
.auth-token-label,
.auth-token-value {
font-size: 1.2em;
margin-top: 5px;
} }
} }
.auth-token-details { .auth-token-icon {
background: $secondary; font-size: 2.25em;
padding: 5px 10px;
margin: 10px 5px 5px 5px;
.auth-token-label {
color: $primary-medium;
}
}
.auth-token-label,
.auth-token-value {
float: left; float: left;
width: 50%; margin-right: 10px;
}
.auth-token-first {
font-size: 1.1em;
.auth-token-device {
font-weight: bold;
}
}
.auth-token-second {
color: $primary-medium;
.active {
color: $success;
font-weight: bold;
}
}
.auth-token-dropdown {
float: right;
.btn,
.btn:hover {
background: transparent;
.d-icon {
color: $primary;
}
}
}
.dropdown-menu {
width: 120px;
& .icon {
margin-top: auto;
}
} }
} }

View File

@ -412,7 +412,7 @@ class ApplicationController < ActionController::Base
end end
def guardian def guardian
@guardian ||= Guardian.new(current_user) @guardian ||= Guardian.new(current_user, request)
end end
def current_homepage def current_homepage

View File

@ -123,11 +123,24 @@ class PostsController < ApplicationController
posts = posts.reject { |post| !guardian.can_see?(post) || post.topic.blank? } posts = posts.reject { |post| !guardian.can_see?(post) || post.topic.blank? }
@posts = posts respond_to do |format|
@title = "#{SiteSetting.title} - #{I18n.t("rss_description.user_posts", username: user.username)}" format.rss do
@link = "#{Discourse.base_url}/u/#{user.username}/activity" @posts = posts
@description = I18n.t("rss_description.user_posts", username: user.username) @title = "#{SiteSetting.title} - #{I18n.t("rss_description.user_posts", username: user.username)}"
render 'posts/latest', formats: [:rss] @link = "#{Discourse.base_url}/u/#{user.username}/activity"
@description = I18n.t("rss_description.user_posts", username: user.username)
render 'posts/latest', formats: [:rss]
end
format.json do
render_json_dump(serialize_data(posts,
PostSerializer,
scope: guardian,
add_excerpt: true)
)
end
end
end end
def cooked def cooked

View File

@ -1108,7 +1108,11 @@ class UsersController < ApplicationController
user = fetch_user_from_params user = fetch_user_from_params
guardian.ensure_can_edit!(user) guardian.ensure_can_edit!(user)
UserAuthToken.where(user_id: user.id).each(&:destroy!) if !SiteSetting.log_out_strict && params[:token_id]
UserAuthToken.where(id: params[:token_id], user_id: user.id).each(&:destroy!)
else
UserAuthToken.where(user_id: user.id).each(&:destroy!)
end
MessageBus.publish "/file-change", ["refresh"], user_ids: [user.id] MessageBus.publish "/file-change", ["refresh"], user_ids: [user.id]

View File

@ -1,11 +1,16 @@
require_dependency 'browser_detection'
require_dependency 'discourse_ip_info'
module UserAuthTokensMixin module UserAuthTokensMixin
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
attributes :id, attributes :id,
:client_ip, :client_ip,
:location,
:browser,
:device,
:os, :os,
:device_name,
:icon, :icon,
:created_at :created_at
end end
@ -14,55 +19,39 @@ module UserAuthTokensMixin
object.client_ip.to_s object.client_ip.to_s
end end
def os def location
case object.user_agent ipinfo = DiscourseIpInfo.get(client_ip)
when /Android/i
'Android' location = [ipinfo[:city], ipinfo[:region], ipinfo[:country]].reject { |x| x.blank? }.join(", ")
when /iPhone|iPad|iPod/i return I18n.t('staff_action_logs.unknown') if location.blank?
'iOS'
when /Macintosh/i location
'macOS'
when /Linux/i
'Linux'
when /Windows/i
'Windows'
else
I18n.t('staff_action_logs.unknown')
end
end end
def device_name def browser
case object.user_agent val = BrowserDetection.browser(object.user_agent)
when /Android/i I18n.t("user_auth_tokens.browser.#{val}")
I18n.t('user_auth_tokens.devices.android') end
when /iPad/i
I18n.t('user_auth_tokens.devices.ipad') def device
when /iPhone/i val = BrowserDetection.device(object.user_agent)
I18n.t('user_auth_tokens.devices.iphone') I18n.t("user_auth_tokens.device.#{val}")
when /iPod/i end
I18n.t('user_auth_tokens.devices.ipod')
when /Mobile/i def os
I18n.t('user_auth_tokens.devices.mobile') val = BrowserDetection.os(object.user_agent)
when /Macintosh/i I18n.t("user_auth_tokens.os.#{val}")
I18n.t('user_auth_tokens.devices.mac')
when /Linux/i
I18n.t('user_auth_tokens.devices.linux')
when /Windows/i
I18n.t('user_auth_tokens.devices.windows')
else
I18n.t('user_auth_tokens.devices.unknown')
end
end end
def icon def icon
case os case BrowserDetection.os(object.user_agent)
when 'Android' when :android
'android' 'android'
when 'macOS', 'iOS' when :macos, :ios
'apple' 'apple'
when 'Linux' when :linux
'linux' 'linux'
when 'Windows' when :windows
'windows' 'windows'
else else
'question' 'question'

View File

@ -9,7 +9,8 @@ class PostSerializer < BasicPostSerializer
:single_post_link_counts, :single_post_link_counts,
:draft_sequence, :draft_sequence,
:post_actions, :post_actions,
:all_post_actions :all_post_actions,
:add_excerpt
] ]
INSTANCE_VARS.each do |v| INSTANCE_VARS.each do |v|
@ -70,7 +71,8 @@ class PostSerializer < BasicPostSerializer
:action_code, :action_code,
:action_code_who, :action_code_who,
:last_wiki_edit, :last_wiki_edit,
:locked :locked,
:excerpt
def initialize(object, opts) def initialize(object, opts)
super(object, opts) super(object, opts)
@ -97,6 +99,10 @@ class PostSerializer < BasicPostSerializer
@add_title @add_title
end end
def include_excerpt?
@add_excerpt
end
def topic_title def topic_title
topic&.title topic&.title
end end

View File

@ -2,4 +2,21 @@ class UserAuthTokenSerializer < ApplicationSerializer
include UserAuthTokensMixin include UserAuthTokensMixin
attributes :seen_at attributes :seen_at
attributes :is_active
def include_is_active?
scope && scope.request
end
def is_active
cookie = scope.request.cookies[Auth::DefaultCurrentUserProvider::TOKEN_COOKIE]
UserAuthToken.hash_token(cookie) == object.auth_token
end
def seen_at
return object.created_at unless object.seen_at.present?
object.seen_at
end
end end

View File

@ -197,8 +197,9 @@ class UserSerializer < BasicUserSerializer
def user_auth_tokens def user_auth_tokens
ActiveModel::ArraySerializer.new( ActiveModel::ArraySerializer.new(
object.user_auth_tokens.order(:seen_at).reverse_order, object.user_auth_tokens,
each_serializer: UserAuthTokenSerializer each_serializer: UserAuthTokenSerializer,
scope: scope
) )
end end

View File

@ -865,16 +865,18 @@ en:
auth_tokens: auth_tokens:
title: "Recently Used Devices" title: "Recently Used Devices"
title_logs: "Authentication Logs" ip: "IP"
ip_address: "IP Address" details: "Details"
created: "Created" log_out_all: "Log out all"
first_seen: "First Seen" active: "active now"
last_seen: "Last Seen" not_you: "Not you?"
operating_system: "Operating System" show_all: "Show all ({{count}})"
location: "Location" show_few: "Show fewer"
action: "Action" was_this_you: "Was this you?"
login: "Log in" was_this_you_description: "If it wasn't you who logged in, we recommend you to change your password and log out of all devices. We also recommend setting up second-factor authentication for a better protection of your account."
logout: "Log out everywhere" browser_and_device: "{{browser}} on {{device}}"
secure_account: "Secure my account"
latest_post: "You last posted..."
last_posted: "Last Post" last_posted: "Last Post"
last_emailed: "Last Emailed" last_emailed: "Last Emailed"

View File

@ -696,16 +696,30 @@ en:
title: "Email login" title: "Email login"
user_auth_tokens: user_auth_tokens:
devices: browser:
android: 'Android Device' chrome: "Google Chrome"
linux: 'Linux Computer' safari: "Safari"
windows: 'Windows Computer' firefox: "Firefox"
mac: 'Mac' opera: "Opera"
iphone: 'iPhone' ie: "Internet Explorer"
ipad: 'iPad' unknown: "unknown browser"
ipod: 'iPod' device:
mobile: 'Mobile Device' android: "Android Device"
unknown: 'Unknown device' ipad: "iPad"
iphone: "iPhone"
ipod: "iPod"
mobile: "Mobile Device"
mac: "Mac"
linux: "Linux Computer"
windows: "Windows Computer"
unknown: "unknown device"
os:
android: "Android"
ios: "iOS"
macos: "macOS"
linux: "Linux"
windows: "Microsoft Windows"
unknown: "unknown operating system"
change_email: change_email:
confirmed: "Your email has been updated." confirmed: "Your email has been updated."

View File

@ -420,6 +420,7 @@ Discourse::Application.routes.draw do
get "#{root_path}/:username/summary" => "users#show", constraints: { username: RouteFormat.username } get "#{root_path}/:username/summary" => "users#show", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/activity/topics.rss" => "list#user_topics_feed", format: :rss, constraints: { username: RouteFormat.username } get "#{root_path}/:username/activity/topics.rss" => "list#user_topics_feed", format: :rss, constraints: { username: RouteFormat.username }
get "#{root_path}/:username/activity.rss" => "posts#user_posts_feed", format: :rss, constraints: { username: RouteFormat.username } get "#{root_path}/:username/activity.rss" => "posts#user_posts_feed", format: :rss, constraints: { username: RouteFormat.username }
get "#{root_path}/:username/activity.json" => "posts#user_posts_feed", format: :json, constraints: { username: RouteFormat.username }
get "#{root_path}/:username/activity" => "users#show", constraints: { username: RouteFormat.username } get "#{root_path}/:username/activity" => "users#show", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/activity/:filter" => "users#show", constraints: { username: RouteFormat.username } get "#{root_path}/:username/activity/:filter" => "users#show", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/badges" => "users#badges", constraints: { username: RouteFormat.username } get "#{root_path}/:username/badges" => "users#badges", constraints: { username: RouteFormat.username }

60
lib/browser_detection.rb Normal file
View File

@ -0,0 +1,60 @@
module BrowserDetection
def self.browser(user_agent)
case user_agent
when /Opera/i, /OPR/i
:opera
when /Firefox/i
:firefox
when /Chrome/i, /CriOS/i
:chrome
when /Safari/i
:safari
when /MSIE/i, /Trident/i
:ie
else
:unknown
end
end
def self.device(user_agent)
case user_agent
when /Android/i
:android
when /iPad/i
:ipad
when /iPhone/i
:iphone
when /iPod/i
:ipod
when /Mobile/i
:mobile
when /Macintosh/i
:mac
when /Linux/i
:linux
when /Windows/i
:windows
else
:unknown
end
end
def self.os(user_agent)
case user_agent
when /Android/i
:android
when /iPhone|iPad|iPod/i
:ios
when /Macintosh/i
:macos
when /Linux/i
:linux
when /Windows/i
:windows
else
:unknown
end
end
end

46
lib/discourse_ip_info.rb Normal file
View File

@ -0,0 +1,46 @@
require_dependency 'maxminddb'
class DiscourseIpInfo
include Singleton
def initialize
begin
@mmdb_filename = File.join(Rails.root, 'vendor', 'data', 'GeoLite2-City.mmdb')
@mmdb = MaxMindDB.new(@mmdb_filename, MaxMindDB::LOW_MEMORY_FILE_READER)
@cache = LruRedux::ThreadSafeCache.new(1000)
rescue Errno::ENOENT => e
Rails.logger.warn("MaxMindDB could not be found: #{e}")
rescue
Rails.logger.warn("MaxMindDB could not be loaded.")
end
end
def lookup(ip)
return {} unless @mmdb
begin
result = @mmdb.lookup(ip)
rescue
Rails.logger.error("IP #{ip} could not be looked up in MaxMindDB.")
end
return {} if !result || !result.found?
{
country: result.country.name,
country_code: result.country.iso_code,
region: result.subdivisions.most_specific.name,
city: result.city.name,
}
end
def get(ip)
return {} unless @mmdb
@cache[ip] ||= lookup(ip)
end
def self.get(ip)
instance.get(ip)
end
end

View File

@ -33,9 +33,11 @@ class Guardian
end end
attr_accessor :can_see_emails attr_accessor :can_see_emails
attr_reader :request
def initialize(user = nil) def initialize(user = nil, request = nil)
@user = user.presence || AnonymousUser.new @user = user.presence || AnonymousUser.new
@request = request
end end
def user def user

22
lib/tasks/maxminddb.rake Normal file
View File

@ -0,0 +1,22 @@
require 'rubygems/package'
require 'zlib'
desc "downloads MaxMind's GeoLite2-City database"
task "maxminddb:get" => :environment do
uri = URI("http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.tar.gz")
tar_gz_archive = Net::HTTP.get(uri)
extractor = Gem::Package::TarReader.new(Zlib::GzipReader.new(StringIO.new(tar_gz_archive)))
extractor.rewind
extractor.each do |entry|
next unless entry.full_name.ends_with?(".mmdb")
filename = File.join(Rails.root, 'vendor', 'data', 'GeoLite2-City.mmdb')
File.open(filename, "wb") do |f|
f.write(entry.read)
end
end
extractor.close
end

View File

@ -0,0 +1,37 @@
require 'rails_helper'
require 'browser_detection'
describe BrowserDetection do
it "detects browser, device and operating system" do
[
["Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1)", :ie, :windows, :windows],
["Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)", :ie, :windows, :windows],
["Mozilla/5.0 (iPad; CPU OS 9_3_2 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13F69 Safari/601.1", :safari, :ipad, :ios],
["Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1", :safari, :iphone, :ios],
["Mozilla/5.0 (iPhone; CPU iPhone OS 9_3_2 like Mac OS X) AppleWebKit/601.1 (KHTML, like Gecko) CriOS/51.0.2704.104 Mobile/13F69 Safari/601.1.46", :chrome, :iphone, :ios],
["Mozilla/5.0 (Linux; Android 4.1.1; Nexus 7 Build/JRO03D) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Safari/535.19", :chrome, :android, :android],
["Mozilla/5.0 (Linux; Android 4.4.2; XMP-6250 Build/HAWK) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/30.0.0.0 Safari/537.36 ADAPI/2.0 (UUID:9e7df0ed-2a5c-4a19-bec7-2cc54800f99d) RK3188-ADAPI/1.2.84.533 (MODEL:XMP-6250)", :chrome, :android, :android],
["Mozilla/5.0 (Linux; Android 5.1; Nexus 7 Build/LMY47O) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.105 Safari/537.36", :chrome, :android, :android],
["Mozilla/5.0 (Linux; Android 6.0.1; vivo 1603 Build/MMB29M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.83 Mobile Safari/537.36", :chrome, :android, :android],
["Mozilla/5.0 (Linux; Android; 4.1.2; GT-I9100 Build/000000) AppleWebKit/537.22 (KHTML, like Gecko) Chrome/25.0.1234.12 Mobile Safari/537.22 OPR/14.0.123.123", :opera, :android, :android],
["Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:54.0) Gecko/20100101 Firefox/54.0", :firefox, :mac, :macos],
["Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:54.0) Gecko/20100101 Firefox/54.0", :firefox, :mac, :macos],
["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36", :chrome, :mac, :macos],
["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36", :chrome, :windows, :windows],
["Mozilla/5.0 (Windows NT 5.1; rv:7.0.1) Gecko/20100101 Firefox/7.0.1", :firefox, :windows, :windows],
["Mozilla/5.0 (Windows NT 6.1; Trident/7.0; rv:11.0) like Gecko", :ie, :windows, :windows],
["Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36", :chrome, :windows, :windows],
["Mozilla/5.0 (Windows NT 6.1; WOW64; rv:40.0) Gecko/20100101 Firefox/40.1", :firefox, :windows, :windows],
["Mozilla/5.0 (Windows NT 6.1; WOW64; rv:54.0) Gecko/20100101 Firefox/54.0", :firefox, :windows, :windows],
["Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.157 Safari/537.36", :chrome, :linux, :linux],
["Mozilla/5.0 (X11; Linux x86_64; rv:10.0) Gecko/20150101 Firefox/47.0 (Chrome)", :firefox, :linux, :linux],
["Opera/9.80 (X11; Linux zvav; U; en) Presto/2.12.423 Version/12.16", :opera, :linux, :linux],
].each do |user_agent, browser, device, os|
expect(BrowserDetection.browser(user_agent)).to eq(browser)
expect(BrowserDetection.device(user_agent)).to eq(device)
expect(BrowserDetection.os(user_agent)).to eq(os)
end
end
end

View File

@ -1460,6 +1460,20 @@ describe PostsController do
expect(body).to_not include(private_post.url) expect(body).to_not include(private_post.url)
expect(body).to include(public_post.url) expect(body).to include(public_post.url)
end end
it 'returns public posts as JSON' do
public_post
private_post
get "/u/#{user.username}/activity.json"
expect(response.status).to eq(200)
body = response.body
expect(body).to_not include(private_post.topic.slug)
expect(body).to include(public_post.topic.slug)
end
end end
describe '#latest' do describe '#latest' do

View File

@ -3242,10 +3242,36 @@ describe UsersController do
context 'while logged in' do context 'while logged in' do
before do before do
sign_in(user) sign_in(user)
sign_in(user)
end end
it 'logs user out' do it 'logs user out' do
SiteSetting.log_out_strict = false
expect(user.user_auth_tokens.count).to eq(2)
ids = user.user_auth_tokens.map { |token| token.id }
post "/u/#{user.username}/preferences/revoke-auth-token.json", params: { token_id: ids[0] }
expect(response.status).to eq(200)
user.user_auth_tokens.reload
expect(user.user_auth_tokens.count).to eq(1) expect(user.user_auth_tokens.count).to eq(1)
expect(user.user_auth_tokens.first.id).to eq(ids[1])
end
it 'logs user out from everywhere if log_out_strict is enabled' do
SiteSetting.log_out_strict = true
expect(user.user_auth_tokens.count).to eq(2)
ids = user.user_auth_tokens.map { |token| token.id }
post "/u/#{user.username}/preferences/revoke-auth-token.json", params: { token_id: ids[0] }
expect(response.status).to eq(200)
expect(user.user_auth_tokens.count).to eq(0)
end
it 'logs user out from everywhere if token_id is not present' do
expect(user.user_auth_tokens.count).to eq(2)
post "/u/#{user.username}/preferences/revoke-auth-token.json" post "/u/#{user.username}/preferences/revoke-auth-token.json"

View File

@ -58,7 +58,7 @@ module Helpers
def stub_guardian(user) def stub_guardian(user)
guardian = Guardian.new(user) guardian = Guardian.new(user)
yield(guardian) if block_given? yield(guardian) if block_given?
Guardian.stubs(new: guardian).with(user) Guardian.stubs(new: guardian).with(user, anything)
end end
def wait_for(on_fail: nil, &blk) def wait_for(on_fail: nil, &blk)

View File

@ -39,6 +39,10 @@ acceptance("User Preferences", {
gravatar_avatar_template: "something" gravatar_avatar_template: "something"
}); });
}); });
server.get("/u/eviltrout/activity.json", () => {
return helper.response({});
});
} }
}); });
@ -248,3 +252,38 @@ QUnit.test("visit my preferences", async assert => {
); );
assert.ok(exists(".user-preferences"), "it shows the preferences"); assert.ok(exists(".user-preferences"), "it shows the preferences");
}); });
QUnit.test("recently connected devices", async assert => {
await visit("/u/eviltrout/preferences");
assert.equal(
find(".pref-auth-tokens > a:first")
.text()
.trim(),
I18n.t("user.auth_tokens.show_all", { count: 3 }),
"it should display two tokens"
);
assert.ok(
find(".pref-auth-tokens .auth-token").length === 2,
"it should display two tokens"
);
await click(".pref-auth-tokens > a:first");
assert.ok(
find(".pref-auth-tokens .auth-token").length === 3,
"it should display three tokens"
);
await click(".auth-token-dropdown:first button");
await click("li[data-value='notYou']");
assert.ok(find(".d-modal:visible").length === 1, "modal should appear");
await click(".modal-footer .btn-primary");
assert.ok(
find(".pref-password.highlighted").length === 1,
"it should highlight password preferences"
);
});

View File

@ -236,7 +236,45 @@ export default {
badge_grouping_id: 8, badge_grouping_id: 8,
system: false, system: false,
badge_type_id: 3 badge_type_id: 3
} },
user_auth_tokens: [
{
id: 2,
client_ip: "188.192.99.49",
location: "Augsburg, Bavaria, Germany",
browser: "Google Chrome",
device: "Linux Computer",
os: "Linux",
icon: "linux",
created_at: "2018-09-08T21:22:56.225Z",
seen_at: "2018-09-08T21:22:56.512Z",
is_active: false
},
{
id: 3,
client_ip: "188.120.223.89",
location: "České Budějovice, České Budějovice District, Czechia",
browser: "Google Chrome",
device: "Linux Computer",
os: "Linux",
icon: "linux",
created_at: "2018-09-08T21:33:41.616Z",
seen_at: "2018-09-08T21:33:42.209Z",
is_active: true
},
{
id: 6,
client_ip: "188.233.223.89",
location: "Tula, Tul'skaya Oblast, Russia",
browser: "Internet Explorer",
device: "Windows Computer",
os: "Windows",
icon: "windows",
created_at: "2018-09-07T21:44:41.616Z",
seen_at: "2018-09-08T21:44:42.209Z",
is_active: false
}
]
} }
}, },
"/user_actions.json": { "/user_actions.json": {