ditty 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +4 -7
- data/.travis.yml +5 -5
- data/Gemfile.ci +2 -0
- data/Rakefile +4 -3
- data/Readme.md +24 -2
- data/ditty.gemspec +4 -3
- data/lib/ditty.rb +24 -0
- data/lib/ditty/cli.rb +6 -2
- data/lib/ditty/components/app.rb +10 -1
- data/lib/ditty/controllers/application.rb +72 -10
- data/lib/ditty/controllers/audit_logs.rb +1 -5
- data/lib/ditty/controllers/auth.rb +37 -17
- data/lib/ditty/controllers/component.rb +15 -5
- data/lib/ditty/controllers/main.rb +1 -5
- data/lib/ditty/controllers/roles.rb +2 -5
- data/lib/ditty/controllers/user_login_traits.rb +18 -0
- data/lib/ditty/controllers/users.rb +4 -9
- data/lib/ditty/db.rb +3 -1
- data/lib/ditty/emails/base.rb +13 -4
- data/lib/ditty/helpers/authentication.rb +6 -5
- data/lib/ditty/helpers/component.rb +9 -1
- data/lib/ditty/helpers/response.rb +24 -3
- data/lib/ditty/helpers/views.rb +20 -0
- data/lib/ditty/listener.rb +38 -10
- data/lib/ditty/middleware/accept_extension.rb +2 -0
- data/lib/ditty/middleware/error_catchall.rb +2 -0
- data/lib/ditty/models/audit_log.rb +1 -0
- data/lib/ditty/models/base.rb +4 -0
- data/lib/ditty/models/identity.rb +3 -0
- data/lib/ditty/models/role.rb +1 -0
- data/lib/ditty/models/user.rb +9 -1
- data/lib/ditty/models/user_login_trait.rb +17 -0
- data/lib/ditty/policies/audit_log_policy.rb +6 -6
- data/lib/ditty/policies/role_policy.rb +2 -2
- data/lib/ditty/policies/user_login_trait_policy.rb +45 -0
- data/lib/ditty/policies/user_policy.rb +2 -2
- data/lib/ditty/rubocop.rb +3 -0
- data/lib/ditty/seed.rb +2 -0
- data/lib/ditty/services/authentication.rb +7 -2
- data/lib/ditty/services/email.rb +8 -2
- data/lib/ditty/services/logger.rb +11 -0
- data/lib/ditty/services/pagination_wrapper.rb +2 -0
- data/lib/ditty/services/settings.rb +14 -3
- data/lib/ditty/tasks/ditty.rake +109 -0
- data/lib/ditty/tasks/omniauth-ldap.rake +43 -0
- data/lib/ditty/version.rb +1 -1
- data/lib/rubocop/cop/ditty/call_services_directly.rb +42 -0
- data/migrate/20181209_add_user_login_traits.rb +16 -0
- data/migrate/20181209_extend_audit_log.rb +12 -0
- data/views/403.haml +2 -0
- data/views/audit_logs/index.haml +11 -6
- data/views/auth/ldap.haml +17 -0
- data/views/emails/forgot_password.haml +1 -1
- data/views/emails/layouts/action.haml +10 -6
- data/views/emails/layouts/alert.haml +2 -1
- data/views/emails/layouts/billing.haml +2 -1
- data/views/error.haml +8 -3
- data/views/partials/form_control.haml +24 -20
- data/views/partials/navbar.haml +11 -12
- data/views/partials/sidebar.haml +1 -1
- data/views/roles/index.haml +2 -0
- data/views/user_login_traits/display.haml +32 -0
- data/views/user_login_traits/edit.haml +10 -0
- data/views/user_login_traits/form.haml +5 -0
- data/views/user_login_traits/index.haml +30 -0
- data/views/user_login_traits/new.haml +10 -0
- data/views/users/display.haml +1 -1
- data/views/users/login_traits.haml +27 -0
- data/views/users/profile.haml +2 -0
- metadata +50 -21
- data/lib/ditty/rake_tasks.rb +0 -102
data/lib/ditty/seed.rb
CHANGED
@@ -1,13 +1,16 @@
|
|
1
|
-
|
2
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'ditty/controllers/application'
|
3
4
|
require 'ditty/services/settings'
|
4
5
|
require 'ditty/services/logger'
|
6
|
+
require 'backports/2.4.0/hash/compact'
|
5
7
|
|
6
8
|
require 'omniauth'
|
7
9
|
OmniAuth.config.logger = Ditty::Services::Logger.instance
|
8
10
|
OmniAuth.config.path_prefix = "#{Ditty::Application.map_path}/auth"
|
9
11
|
OmniAuth.config.on_failure = proc { |env|
|
10
12
|
next [400, {}, []] if env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
|
13
|
+
|
11
14
|
OmniAuth::FailureEndpoint.new(env).redirect_to_failure
|
12
15
|
}
|
13
16
|
|
@@ -42,6 +45,8 @@ module Ditty
|
|
42
45
|
end
|
43
46
|
|
44
47
|
def default
|
48
|
+
require 'ditty/models/identity'
|
49
|
+
require 'ditty/controllers/auth'
|
45
50
|
{
|
46
51
|
identity: {
|
47
52
|
arguments: [
|
data/lib/ditty/services/email.rb
CHANGED
@@ -11,6 +11,8 @@ module Ditty
|
|
11
11
|
class << self
|
12
12
|
include ActiveSupport::Inflector
|
13
13
|
|
14
|
+
attr_writer :config
|
15
|
+
|
14
16
|
def config!
|
15
17
|
cfg = config
|
16
18
|
Mail.defaults do
|
@@ -21,6 +23,7 @@ module Ditty
|
|
21
23
|
def deliver(email, to = nil, options = {})
|
22
24
|
config!
|
23
25
|
options[:to] ||= to unless to.nil?
|
26
|
+
options[:from] ||= config[:from] unless config[:from].nil?
|
24
27
|
email = from_symbol(email, options) if email.is_a? Symbol
|
25
28
|
email.deliver!
|
26
29
|
end
|
@@ -28,11 +31,14 @@ module Ditty
|
|
28
31
|
private
|
29
32
|
|
30
33
|
def config
|
31
|
-
default.merge Ditty::Services::Settings.values(:email) || {}
|
34
|
+
@config ||= default.merge Ditty::Services::Settings.values(:email) || {}
|
32
35
|
end
|
33
36
|
|
34
37
|
def default
|
35
|
-
{
|
38
|
+
{
|
39
|
+
delivery_method: :logger,
|
40
|
+
logger: Ditty::Services::Logger.instance
|
41
|
+
}
|
36
42
|
end
|
37
43
|
|
38
44
|
def from_symbol(email, options)
|
@@ -20,6 +20,7 @@ module Ditty
|
|
20
20
|
def initialize
|
21
21
|
@loggers = []
|
22
22
|
return if config[:loggers].blank?
|
23
|
+
|
23
24
|
config[:loggers].each do |values|
|
24
25
|
klass = values[:class].constantize
|
25
26
|
opts = tr(values[:options]) || nil
|
@@ -53,6 +54,16 @@ module Ditty
|
|
53
54
|
def default
|
54
55
|
{ loggers: [{ name: 'default', class: 'Logger' }] }
|
55
56
|
end
|
57
|
+
|
58
|
+
class << self
|
59
|
+
def method_missing(method, *args, &block)
|
60
|
+
instance.send(method, *args, &block)
|
61
|
+
end
|
62
|
+
|
63
|
+
def respond_to_missing?(method, _include_private)
|
64
|
+
instance.respond_to? method
|
65
|
+
end
|
66
|
+
end
|
56
67
|
end
|
57
68
|
end
|
58
69
|
end
|
@@ -16,12 +16,19 @@ module Ditty
|
|
16
16
|
# in separate files will be used in preference of those in the `settings.yml`
|
17
17
|
# file.
|
18
18
|
module Settings
|
19
|
-
CONFIG_FOLDER = './config'
|
20
|
-
CONFIG_FILE = "#{CONFIG_FOLDER}/settings.yml"
|
19
|
+
CONFIG_FOLDER = './config'
|
20
|
+
CONFIG_FILE = "#{CONFIG_FOLDER}/settings.yml"
|
21
21
|
|
22
22
|
class << self
|
23
23
|
def [](key)
|
24
|
-
|
24
|
+
keys = key.to_s.split('.').map(&:to_sym)
|
25
|
+
from = values
|
26
|
+
if keys.count > 1 && scope?(keys.first)
|
27
|
+
from = values(keys.first)
|
28
|
+
keys = keys[1..-1]
|
29
|
+
key = keys.join('.')
|
30
|
+
end
|
31
|
+
from[key.to_sym] || from.dig(*keys)
|
25
32
|
end
|
26
33
|
|
27
34
|
def values(scope = :settings)
|
@@ -40,6 +47,10 @@ module Ditty
|
|
40
47
|
@values[scope]
|
41
48
|
end
|
42
49
|
|
50
|
+
def scope?(name)
|
51
|
+
@values.key?(name.to_sym) || File.file?("#{CONFIG_FOLDER}/#{name}.yml")
|
52
|
+
end
|
53
|
+
|
43
54
|
attr_writer :values
|
44
55
|
|
45
56
|
def read(filename)
|
@@ -0,0 +1,109 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
namespace :ditty do
|
4
|
+
desc 'Prepare Ditty'
|
5
|
+
task prep: ['generate_tokens', 'prep:folders', 'prep:public', 'prep:migrations']
|
6
|
+
|
7
|
+
desc 'Generate the needed tokens'
|
8
|
+
task :generate_tokens do
|
9
|
+
puts 'Generating the Ditty tokens'
|
10
|
+
require 'securerandom'
|
11
|
+
File.write('.session_secret', SecureRandom.random_bytes(40)) unless File.file?('.session_secret')
|
12
|
+
File.write('.token_secret', SecureRandom.random_bytes(40)) unless File.file?('.token_secret')
|
13
|
+
end
|
14
|
+
|
15
|
+
desc 'Seed the Ditty database'
|
16
|
+
task :seed do
|
17
|
+
puts 'Seeding the Ditty database'
|
18
|
+
require 'ditty/seed'
|
19
|
+
end
|
20
|
+
|
21
|
+
desc 'Dump the Ditty DB Schema'
|
22
|
+
task :dump_schema do
|
23
|
+
Ditty::Components.components.each do |_name, comp|
|
24
|
+
comp.load if comp.respond_to?(:load)
|
25
|
+
end.compact
|
26
|
+
DB.dump_schema_cache('./config/schema.dump')
|
27
|
+
end
|
28
|
+
|
29
|
+
namespace :prep do
|
30
|
+
desc 'Check that the required Ditty folders are present'
|
31
|
+
task :folders do
|
32
|
+
puts 'Prepare the Ditty folders'
|
33
|
+
Dir.mkdir 'pids' unless File.exist?('pids')
|
34
|
+
end
|
35
|
+
|
36
|
+
desc 'Check that the public folder is present and populated'
|
37
|
+
task :public do
|
38
|
+
puts 'Preparing the Ditty public folder'
|
39
|
+
Dir.mkdir 'public' unless File.exist?('public')
|
40
|
+
::Ditty::Components.public_folder.each do |path|
|
41
|
+
puts "Checking #{path}"
|
42
|
+
path = "#{path}/."
|
43
|
+
FileUtils.cp_r path, 'public' unless File.expand_path(path).eql? File.expand_path('public')
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
desc 'Check that the migrations folder is present and populated'
|
48
|
+
task :migrations do
|
49
|
+
puts 'Preparing the Ditty migrations folder'
|
50
|
+
Dir.mkdir 'migrations' unless File.exist?('migrations')
|
51
|
+
::Ditty::Components.migrations.each do |path|
|
52
|
+
path = "#{path}/."
|
53
|
+
FileUtils.cp_r path, 'migrations' unless File.expand_path(path).eql? File.expand_path('migrations')
|
54
|
+
end
|
55
|
+
puts 'Migrations added:'
|
56
|
+
Dir.foreach('migrations').sort.each { |x| puts x if File.file?("migrations/#{x}") && x[-3..-1] == '.rb' }
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
desc 'Migrate Ditty database to latest version'
|
61
|
+
task migrate: ['prep:migrations'] do
|
62
|
+
puts 'Running the Ditty migrations'
|
63
|
+
Rake::Task['ditty:migrate:up'].invoke
|
64
|
+
end
|
65
|
+
|
66
|
+
namespace :migrate do
|
67
|
+
require 'logger'
|
68
|
+
|
69
|
+
folder = 'migrations'
|
70
|
+
|
71
|
+
desc 'Check if the migration is current'
|
72
|
+
task :check do
|
73
|
+
::DB.loggers << Logger.new($stdout) if ::DB.loggers.count.zero?
|
74
|
+
puts '** [ditty] Running Ditty Sequel Migrations check'
|
75
|
+
::Sequel.extension :migration
|
76
|
+
begin
|
77
|
+
::Sequel::Migrator.check_current(::DB, folder)
|
78
|
+
puts '** [ditty] Sequel Migrations up to date'
|
79
|
+
rescue Sequel::Migrator::Error => _e
|
80
|
+
raise 'Sequel Migrations NOT up to date'
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
desc 'Migrate Ditty database to latest version'
|
85
|
+
task :up do
|
86
|
+
::DB.loggers << Logger.new($stdout) if ::DB.loggers.count.zero?
|
87
|
+
puts '** [ditty] Running Ditty Migrations up'
|
88
|
+
::Sequel.extension :migration
|
89
|
+
::Sequel::Migrator.apply(::DB, folder)
|
90
|
+
end
|
91
|
+
|
92
|
+
desc 'Remove the whole Ditty database. You WILL lose data'
|
93
|
+
task :down do
|
94
|
+
::DB.loggers << Logger.new($stdout) if ::DB.loggers.count.zero?
|
95
|
+
puts '** [ditty] Running Ditty Migrations down'
|
96
|
+
::Sequel.extension :migration
|
97
|
+
::Sequel::Migrator.apply(::DB, folder, 0)
|
98
|
+
end
|
99
|
+
|
100
|
+
desc 'Reset the Ditty database. You WILL lose data'
|
101
|
+
task :bounce do
|
102
|
+
::DB.loggers << Logger.new($stdout) if ::DB.loggers.count.zero?
|
103
|
+
puts '** [ditty] Running Ditty Migrations bounce'
|
104
|
+
::Sequel.extension :migration
|
105
|
+
::Sequel::Migrator.apply(::DB, folder, 0)
|
106
|
+
::Sequel::Migrator.apply(::DB, folder)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
namespace :ditty do
|
4
|
+
namespace :ldap do
|
5
|
+
desc 'Check the LDAP settings'
|
6
|
+
task :check do
|
7
|
+
settings = Ditty::Services::Settings[:authentication][:ldap][:arguments].first
|
8
|
+
ldap = Net::LDAP.new host: settings[:host], port: settings[:port]
|
9
|
+
ldap.authenticate settings[:bind_dn], settings[:password] if settings[:bind_dn]
|
10
|
+
raise 'Could not bind to LDAP server' unless ldap.bind
|
11
|
+
|
12
|
+
puts 'LDAP Binding Successful'
|
13
|
+
end
|
14
|
+
|
15
|
+
desc 'Add the AD / LDAP Groups to Ditty as Roles'
|
16
|
+
task :populate_groups, [:filter] do |_task, args|
|
17
|
+
puts 'Adding AD / LDAP Groups to Ditty as Roles'
|
18
|
+
require 'ditty/services/settings'
|
19
|
+
require 'ditty/models/role'
|
20
|
+
|
21
|
+
settings = Ditty::Services::Settings[:authentication][:ldap][:arguments].first
|
22
|
+
ldap = Net::LDAP.new host: settings[:host], port: settings[:port]
|
23
|
+
ldap.authenticate settings[:bind_dn], settings[:password] if settings[:bind_dn]
|
24
|
+
if ldap.bind
|
25
|
+
group_filter = Net::LDAP::Filter.construct(settings[:group_filter]) unless settings[:group_filter].blank?
|
26
|
+
group_filter ||= Net::LDAP::Filter.eq('ObjectClass', 'Group')
|
27
|
+
if args[:filter]
|
28
|
+
search_filter = Net::LDAP::Filter.eq(*args[:filter].split(':', 2))
|
29
|
+
filter = Net::LDAP::Filter.join(group_filter, search_filter)
|
30
|
+
else
|
31
|
+
filter = group_filter
|
32
|
+
end
|
33
|
+
ldap.search(base: settings[:base], filter: filter).each do |group|
|
34
|
+
Ditty::Role.find_or_create(name: group.name) do |role|
|
35
|
+
puts "Adding #{role.name}"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
else
|
39
|
+
puts 'Could not connect to LDAP Server'
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
data/lib/ditty/version.rb
CHANGED
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rubocop'
|
4
|
+
require 'rubocop/ast/node'
|
5
|
+
require 'rubocop/cop/cop'
|
6
|
+
|
7
|
+
module RuboCop
|
8
|
+
module Cop
|
9
|
+
module Ditty
|
10
|
+
# This cop enforces the use of `Service.method` instead of
|
11
|
+
# `Service.instance.method`. Calling the singleton instance has been
|
12
|
+
# deprecated for services.
|
13
|
+
#
|
14
|
+
# @example
|
15
|
+
# # bad
|
16
|
+
# Ditty::Services::Logger.instance.info 'This is a log message'
|
17
|
+
#
|
18
|
+
# # good
|
19
|
+
# Ditty::Services::Logger.info 'This is a log message'
|
20
|
+
class CallServicesDirectly < RuboCop::Cop::Cop
|
21
|
+
MSG = 'Do not use `.instance` on services. Call the method directly instead'
|
22
|
+
|
23
|
+
def_node_matcher :service_instance_call?, <<-PATTERN
|
24
|
+
(send (const (const (const ... :Ditty) :Services) _) :instance)
|
25
|
+
PATTERN
|
26
|
+
|
27
|
+
def on_send(node)
|
28
|
+
return unless service_instance_call?(node)
|
29
|
+
|
30
|
+
add_offense(node)
|
31
|
+
end
|
32
|
+
|
33
|
+
def autocorrect(node)
|
34
|
+
lambda do |corrector|
|
35
|
+
internal = node.children.first.source
|
36
|
+
corrector.replace(node.loc.expression, internal)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
Sequel.migration do
|
4
|
+
change do
|
5
|
+
create_table :user_login_traits do
|
6
|
+
primary_key :id
|
7
|
+
foreign_key :user_id, :users
|
8
|
+
String :ip_address, nullable: true
|
9
|
+
String :platform, nullable: true
|
10
|
+
String :device, nullable: true
|
11
|
+
String :browser, nullable: true
|
12
|
+
DateTime :created_at
|
13
|
+
DateTime :updated_at
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
data/views/403.haml
ADDED
data/views/audit_logs/index.haml
CHANGED
@@ -9,6 +9,10 @@
|
|
9
9
|
%th User email
|
10
10
|
%th Action
|
11
11
|
%th Details
|
12
|
+
%th IP Address
|
13
|
+
%th Browser
|
14
|
+
%th Device
|
15
|
+
%th Platform
|
12
16
|
%th Created at
|
13
17
|
%tbody
|
14
18
|
- if list.count > 0
|
@@ -19,12 +23,13 @@
|
|
19
23
|
%a{ href: "#{settings.map_path}/users/#{entity.user.id}" }= entity.user.email
|
20
24
|
-else
|
21
25
|
None
|
22
|
-
%td
|
23
|
-
|
24
|
-
%td
|
25
|
-
|
26
|
-
%td
|
27
|
-
|
26
|
+
%td= entity.action
|
27
|
+
%td= entity.details
|
28
|
+
%td= entity.ip_address || 'Unknown'
|
29
|
+
%td= entity.browser || 'Unknown'
|
30
|
+
%td= entity.device || 'Unknown'
|
31
|
+
%td= entity.platform || 'Unknown'
|
32
|
+
%td= entity.created_at&.strftime('%Y-%m-%d %H:%M:%S') || 'Unknown'
|
28
33
|
- else
|
29
34
|
%tr
|
30
35
|
%td.text-center{ colspan: 4 } No records
|
@@ -0,0 +1,17 @@
|
|
1
|
+
.row
|
2
|
+
.col-sm-2
|
3
|
+
.col-sm-8
|
4
|
+
.panel.panel-default
|
5
|
+
.panel-body
|
6
|
+
= form_tag("#{settings.map_path}/auth/ldap/callback", attributes: { class: '' }) do
|
7
|
+
.form-group
|
8
|
+
%label.control-label Username
|
9
|
+
%input.form-control.border-input{ name: 'username', tabindex: '1' }
|
10
|
+
.form-group
|
11
|
+
%label.control-label{ style: 'display: block' }
|
12
|
+
Password
|
13
|
+
%input.form-control.border-input{ name: 'password', type: 'password', tabindex: '2' }
|
14
|
+
%button.btn.btn-primary{ type: 'submit', tabindex: '3' }
|
15
|
+
%i.fa.fa-building
|
16
|
+
Log In
|
17
|
+
.col-sm-2
|
@@ -3,7 +3,8 @@
|
|
3
3
|
%head
|
4
4
|
%meta{:content => "width=device-width", :name => "viewport"}/
|
5
5
|
%meta{:content => "text/html; charset=UTF-8", "http-equiv" => "Content-Type"}/
|
6
|
-
%title
|
6
|
+
%title
|
7
|
+
= subject
|
7
8
|
:css
|
8
9
|
img {
|
9
10
|
max-width: 100%;
|
@@ -59,10 +60,13 @@
|
|
59
60
|
%td.container{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; display: block !important; max-width: 600px !important; clear: both !important; margin: 0 auto;", :valign => "top", :width => "600"}
|
60
61
|
.content{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; max-width: 600px; display: block; margin: 0 auto; padding: 20px;"}
|
61
62
|
= content
|
62
|
-
-
|
63
|
-
|
64
|
-
%
|
65
|
-
%
|
66
|
-
|
63
|
+
.footer{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; clear: both; color: #999; margin: 0; padding: 20px;"}
|
64
|
+
%table{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;", :width => "100%"}
|
65
|
+
%tr{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;"}
|
66
|
+
%td.aligncenter.content-block{:align => "center", :style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 12px; vertical-align: top; color: #999; text-align: center; margin: 0; padding: 0 0 20px;", :valign => "top"}
|
67
|
+
- if defined? footer
|
67
68
|
= footer
|
69
|
+
- else
|
70
|
+
This email was sent to
|
71
|
+
= to
|
68
72
|
%td{:style => "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;", :valign => "top"}
|