diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 3efd037..7f63099 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1,51 +1,55 @@ /* * This is a manifest file that'll be compiled into application.css, which will include all the files * listed below. * * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path. * * You're free to add application-wide styles to this file and they'll appear at the bottom of the * compiled file so the styles you add here take precedence over styles defined in any styles * defined in the other CSS/SCSS files in this directory. It is generally better to create a new * file per style scope. * *= require bootstrap.min *= require dataTables.bootstrap.min *= require bootstrap-editable *= require_tree . *= require_self */ /* Make sure navbar does not overlay body */ body { padding-top: 70px; } .highlight , .highlight>td { background-color: #b8e0b8 !important; } /* DataTable resets bootstrap's margin-bottom */ .datatable-wrapper { margin-bottom: 20px; } /* Reset bootstrap's help cursor on control links */ table a abbr[title] { cursor: pointer; } .tab-pane table { margin-top: 20px; } #inline-record-form #record_ttl { width: 80px; } #inline-record-form #record_prio { width: 80px; } #inline-record-form #record_content { width: 300px; } + +#domains span.glyphicon-volume-up { + color: red; +} diff --git a/app/controllers/domains_controller.rb b/app/controllers/domains_controller.rb index 5c9903d..35f001f 100644 --- a/app/controllers/domains_controller.rb +++ b/app/controllers/domains_controller.rb @@ -1,104 +1,107 @@ +require 'set' + class DomainsController < ApplicationController before_action :authenticate_user! before_action :domain, only: [:show, :edit, :edit_dnssec, :update, :destroy, :full_destroy] before_action :group, only: [:show, :edit, :edit_dnssec, :update, :destroy, :full_destroy] helper_method :edit_group_scope # GET /domains def index @domains = show_domain_scope.includes(:group, :soa).all + @optouts = Set.new current_user.subscriptions.pluck(:domain_id) end # GET /domains/1 def show @record = Record.new(domain_id: @domain.id) end # GET /domains/new def new @domain = Domain.new(new_domain_params) end # GET /domains/1/edit def edit end # GET /domains/1/edit_dnssec def edit_dnssec end # POST /domains def create @domain = Domain.new(domain_params) if @domain.save notify_domain(@domain, :create) redirect_to @domain, notice: "#{@domain.name} was successfully created." else render :new end end # PATCH/PUT /domains/1 def update if @domain.update(domain_params) notify_domain(@domain, :update) redirect_to @domain, notice: "#{@domain.name} was successfully updated." else if domain_params[:dnssec] # DNSSEC form render :edit_dnssec else render :edit end end end # DELETE /domains/1 def destroy if @domain.remove notify_domain(@domain, :destroy) redirect_to domains_url, notice: "#{@domain.name} is scheduled for removal." else redirect_to domains_url, alert: "#{@domain.name} cannot be deleted! (state '#{@domain.state}')" end end # DELETE /domains/1/full_destroy def full_destroy if @domain.full_remove notify_domain(@domain, :destroy) redirect_to domains_url, notice: "#{@domain.name} is scheduled for full removal. DS records will be dropped from the parent zone before proceeding" else redirect_to domains_url, alert: "#{@domain.name} cannot be deleted! (state '#{@domain.state}')" end end private def group domain.group end def new_domain_params params.permit(:group_id) end def domain_params params.require(:domain).tap { |d| # Make sure group id is permitted (belongs to edit_group_scope) d[:group_id] = edit_group_scope.find_by_id(d[:group_id]).try(:id) # Sometimes domain name might contain whitespace, make sure we remove # them. Note that we use a regex to handle unicode whitespace characters as well. d[:name] = d[:name].gsub(/\p{Space}/, '') if d[:name] }.permit(:name, :type, :master, :group_id, :dnssec, :dnssec_parent, :dnssec_parent_authority, :dnssec_policy_id) end def notify_domain(*args) notification.notify_domain(current_user, *args) if WebDNS.settings[:notifications] end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 0d0c3eb..d22f844 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -1,53 +1,54 @@ class GroupsController < ApplicationController before_action :authenticate_user! before_action :group, only: [:show, :create_member, :destroy_member, :search_member] before_action :user, only: [:destroy_member] # GET /groups/1 def show @domains = @group.domains + @optouts = Set.new current_user.subscriptions.where(domain_id: @domains).pluck(:domain_id) end # POST /groups/1/members/ def create_member @user = User.find_by_email(params[:email]) if !@user redirect_to group_path(@group, anchor: 'tab-members'), alert: "User '#{params[:email]}' not found!" return end membership = @group.memberships.find_or_create_by!(user_id: @user.id) redirect_to group_path(@group, anchor: 'tab-members'), notice: "#{membership.user.email} is now a member of #{@group.name}" end # DELETE /groups/1/member/1 def destroy_member membership = @group.memberships.find_by!(user_id: user.id) membership.destroy! redirect_to @group, notice: "#{membership.user.email} was successfully removed from #{@group.name}" end def search_member results = [] if params[:q].present? uids = group.users.pluck(:id) results = User .where('email like ?', "#{params[:q]}%") .where.not(id: uids) # Exclude group members .limit(10) end render json: results.map { |r| Hash[:id, r.id, :email, r.email] } end private def user @user ||= User.find(params[:user_id]) end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index ef79c33..01aae98 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,31 +1,48 @@ class UsersController < ApplicationController before_action :authenticate_user! - before_action :user, only: [:token, :generate_token] + before_action :user, only: [:mute, :unmute, :token, :generate_token] # GET /users/1/token def token end # POST /users/1/generate_token def generate_token @user.token = SecureRandom.hex(10) @user.save! redirect_to token_user_path(@user) end + # PUT /users/1/unsubscribe/2 + def mute + domain = show_domain_scope.find(params[:domain_id]) + @user.subscriptions.find_or_create_by!(domain: domain) + + redirect_to domains_url, notice: "Successfully unsubscribed from #{domain.name} notifications!" + end + + # PUT /users/1/subscribe/2 + def unmute + domain = show_domain_scope.find(params[:domain_id]) + # Drop all opt-outs + @user.subscriptions.where(domain: domain).delete_all + + redirect_to domains_url, notice: "Successfully subscribed to #{domain.name} notifications!" + end + private def user - @user ||= User.find(params[:id]) + @user ||= User.find(params[:user_id] || params[:id]) # Guard access to other user tokens if current_user.id != @user.id && !admin? redirect_to(root_path, alert: 'You need admin rights for that!') end @user end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index b48b410..7a7b4d0 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,57 +1,65 @@ module ApplicationHelper TIME_PERIODS = { 1.second => 'second', 1.minute => 'minute', 1.hour => 'hour', 1.day => 'day', 1.week => 'week', 1.month => 'month', 1.year.to_i => 'year', } def can_edit?(object) return true unless object.respond_to?(:editable?) by = admin? ? :admin : :user object.editable?(by) end def seconds_to_human(seconds) acc = {} remaining = seconds TIME_PERIODS.to_a.reverse_each do |p, human| period_count, remaining = remaining.divmod(p) acc[human] = period_count if not period_count.zero? end acc.map { |singular, count| human = count < 2 ? singular : "#{singular}s" "#{count} #{human}" }.join(', ') end def link_to_edit(*args, &block) link_to(abbr_glyph(:pencil, 'Edit'), *args, &block) end def link_to_destroy(*args, &block) link_to(abbr_glyph(:remove, 'Remove'), *args, &block) end def link_to_enable(*args, &block) link_to(abbr_glyph(:'eye-close', 'Enable'), *args, &block) end def link_to_disable(*args, &block) link_to(abbr_glyph(:'eye-open', 'Disable'), *args, &block) end + def link_to_mute(*args, &block) + link_to(abbr_glyph(:'volume-off', 'Disable notifications'), *args, &block) + end + + def link_to_unmute(*args, &block) + link_to(abbr_glyph(:'volume-up', 'Renable notifications'), *args, &block) + end + def glyph(icon) content_tag(:span, '', class: "glyphicon glyphicon-#{icon}") end def abbr_glyph(icon, title) content_tag(:abbr, glyph(icon), title: title) end end diff --git a/app/views/domains/index.html.erb b/app/views/domains/index.html.erb index a95799f..22beec0 100644 --- a/app/views/domains/index.html.erb +++ b/app/views/domains/index.html.erb @@ -1,56 +1,61 @@ <% if current_user.memberships.empty? %>

Wellcome to WebDNS!

In order to manage domains you have to be a member of a group.

You can either contact an admin to create a new group for you, or ask another user for an invite to an existing group.

<% end %>
<% @domains.group_by(&:group).each do |group, domains| %> <% domains.each do |domain| %> <% end %> <% end %>
Domain Serial Group State Slave DNSSEC Controls
<%= link_to domain.name, domain %> <%= domain.serial %> <%= link_to group.name, group_path(group) %> <%= human_state(domain.state) %> <%= domain.slave? ? domain.master : '-' %> <%= domain.dnssec? ? 'secure' : '-' %> <%= link_to_edit edit_domain_path(domain) %> + <% if @optouts.include? domain.id %> + <%= link_to_unmute user_domain_unmute_path(current_user, domain), method: :put %> + <% else %> + <%= link_to_mute user_domain_mute_path(current_user, domain), method: :put %> + <% end %> <%= link_to_destroy domain, method: :delete, data: { confirm: 'Are you sure?' } if domain.can_remove? %> <%= link_to_full_destroy full_destroy_domain_path(domain), method: :delete, data: { confirm: 'Are you sure?' } if domain.can_remove? && domain.dnssec? %>

<% if current_user.memberships.any? %> <%= link_to 'Add Domain', new_domain_path, class: 'btn btn-primary' %> <% else %> <%= link_to 'Add Domain', new_domain_path, class: 'btn btn-primary disabled' %> <% end %>

diff --git a/app/views/groups/show.html.erb b/app/views/groups/show.html.erb index 7874355..d81de70 100644 --- a/app/views/groups/show.html.erb +++ b/app/views/groups/show.html.erb @@ -1,87 +1,92 @@ <% content_for :more_breadcrumbs do %>
  • <%= link_to_edit edit_admin_group_path(@group) %> <%= link_to_destroy admin_group_path(@group), method: :delete, data: { confirm: 'Are you sure?' } %>
  • <% end if admin? %>
    <% @domains.group_by(&:group).each do |group, domains| %> <% domains.each do |domain| %> <% end %> <% end %>
    Domain Serial Group State Slave DNSSEC Controls
    <%= link_to domain.name, domain %> <%= domain.serial %> <%= link_to group.name, group_path(group) %> <%= human_state(domain.state) %> <%= domain.slave? ? domain.master : '-' %> <%= domain.dnssec? ? 'secure' : '-' %> <%= link_to_edit edit_domain_path(domain) %> + <% if @optouts.include? domain.id %> + <%= link_to_unmute user_domain_unmute_path(current_user, domain), method: :put %> + <% else %> + <%= link_to_mute user_domain_mute_path(current_user, domain), method: :put %> + <% end %> <%= link_to_destroy domain, method: :delete, data: { confirm: 'Are you sure?' } if domain.can_remove? %> <%= link_to_full_destroy full_destroy_domain_path(domain), method: :delete, data: { confirm: 'Are you sure?' } if domain.can_remove? && domain.dnssec? %>

    <% if current_user.memberships.any? %> <%= link_to 'Add Domain', new_domain_path(group_id: @group.id), class: 'btn btn-primary' %> <% else %> <%= link_to 'Add Domain', new_domain_path(group_id: @group.id), class: 'btn btn-primary disabled' %> <% end %>

    <% @group.memberships.includes(:user).each do |membership| %> <% end %>
    Member Controls
    <%= membership.user.email %><%= " (you)" if current_user == membership.user %> <%= link_to_destroy destroy_member_group_path(@group, membership.user_id), method: :delete %>

    <%= bootstrap_form_tag(url: create_member_group_path(@group), layout: :inline) do |f| %> <%= f.text_field :email, prepend: 'Add Member', hide_label: true, id: 'js-search-member', data: { group: @group.id } %> <%= f.submit 'Add', class: 'btn btn-primary' %> <% end %>

    diff --git a/config/routes.rb b/config/routes.rb index cb736ad..6c277dc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,82 +1,86 @@ Rails.application.routes.draw do # Override devise user removal devise_scope :users do delete :users, to: redirect('/') end devise_for :users get '/auth/saml', to: 'auth#saml' root to: redirect('/domains') resources :users, only: [] do get :token, to: 'users#token', on: :member post :generate_token, to: 'users#generate_token', on: :member + resources :domains, only: [] do + put :mute, to: 'users#mute' + put :unmute, to: 'users#unmute' + end end resources :groups, only: [:show] do get :search_member, to: 'groups#search_member', on: :member post :members, to: 'groups#create_member', as: :create_member, on: :member delete 'member/:user_id', to: 'groups#destroy_member', as: :destroy_member, on: :member end resources :domains do get :edit_dnssec, to: 'domains#edit_dnssec', on: :member delete :full_destroy, to: 'domains#full_destroy', on: :member resources :records, except: [:index, :show] do # Reuse records#update instead of introducing new controller actions # # rubocop:disable Style/AlignHash put :disable, to: 'records#update', on: :member, defaults: { record: { disabled: true } } put :enable, to: 'records#update', on: :member, defaults: { record: { disabled: false } } put :editable, to: 'records#editable', on: :collection post :valid, to: 'records#valid', on: :collection post :bulk, to: 'records#bulk', on: :collection # rubocop:enable Style/AlignHash end end get '/records/search', to: 'records#search' # Admin namespace :admin do root to: redirect('/admin/groups') resources :groups, except: [:show] resources :jobs, only: [:index, :destroy] do put :done, to: 'jobs#update', on: :member, defaults: { job: { status: 1 } } put :pending, to: 'jobs#update', on: :member, defaults: { job: { status: 0 } } get '/type/:category', to: 'jobs#index', on: :collection, constraints: proc { |req| ['completed', 'pending'].include?(req.params[:category]) } end resources :users, only: [:destroy] do get :orphans, to: 'users#orphans', on: :collection put :update_groups, to: 'users#update_groups', on: :collection end end # API scope '/api' do get :ping, to: 'api#ping' get :whoami, to: 'api#whoami' get '/domain/:domain/list', to: 'api#list', constraints: { domain: /[^\/]+/} post '/domain/:domain/bulk', to: 'api#bulk', constraints: { domain: /[^\/]+/} get :domains, to: 'api#domains' end if WebDNS.settings[:api] # Private put 'private/replace_ds', to: 'private#replace_ds' put 'private/trigger_event', to: 'private#trigger_event' get 'private/zones', to: 'private#zones' get 'help/api', to: 'help#api' end