diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 805a5e4..ff601b5 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -1,107 +1,129 @@ // This is a manifest file that'll be compiled into application.js, which will include all the files // listed below. // // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, // or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path. // // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the // compiled file. // // Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details // about supported directives. // //= require jquery.min //= require jquery_ujs //= require bootstrap.min //= require typeahead.bundle.min //= require jquery.dataTables.min //= require dataTables.bootstrap.min //= require bootstrap-editable.min //= require_tree . $(function() { + // Setup X-Editable + $.fn.editable.defaults.mode = 'inline'; + $.fn.editable.defaults.ajaxOptions = { + type: 'put', + dataType: 'json' + }; + + function editable_record_success(response, _value) { + // Visual hint for the changed serial + if(response.serial) { + $('tr.soa .soa-serial').text(response.serial); + $('tr.soa .soa-serial').fadeOut(100).fadeIn(500); + } + + // Render the value returned by the server as + // there are cases where the value is normalized (e.x. name) + return { newValue: response.value }; + } + + $('table .editable').editable({ + success: editable_record_success + }); // Show priority on MX/SRV record only $('#record_type').change(function() { if ($(this).val() == 'MX') { // MX, default priority 10 $('#record_prio.autohide').parents('div.form-group').removeClass('hidden'); $('#record_prio.autodisable').prop('disabled', false); $('#record_prio').val('10'); } else if ($(this).val() == 'SRV') { // SRV $('#record_prio').val(''); $('#record_prio.autohide').parents('div.form-group').removeClass('hidden'); $('#record_prio.autodisable').prop('disabled', false); } else { $('#record_prio').val(''); $('#record_prio.autohide').parents('div.form-group').addClass('hidden'); $('#record_prio.autodisable').prop('disabled', true); } }); // Show master only on SLAVE domains $('#domain_type').change(function() { if ($(this).val() == 'SLAVE') { $('#domain_master').parents('div.form-group').removeClass('hidden'); } else { $('#domain_master').parents('div.form-group').addClass('hidden'); } }); // Disable DNSSEC options $('#domain_dnssec').change(function() { if ($(this).val()== 'true') { $("#dnssec_fieldset").prop('disabled', false) } else { $("#dnssec_fieldset").prop('disabled', true); } }); var searchMembersGroup = $('#js-search-member').data('group'); var searchMembers = new Bloodhound({ datumTokenizer: Bloodhound.tokenizers.obj.whitespace('email'), queryTokenizer: Bloodhound.tokenizers.whitespace, identify: function(obj) { return obj.id; }, remote: { url: '/groups/' + searchMembersGroup + '/search_member.json?q=%QUERY', wildcard: '%QUERY' } }); $('#js-search-member').typeahead({ hint: true, minLength: 2 }, { name: 'members', display: 'email', source: searchMembers }); // Highlighter helper // // Applies 'highlight' class to the element followed by 'hl-' prefix function highlighter() { $('.highlight').removeClass('highlight'); if (!window.location.hash) return; if (window.location.hash.indexOf('#hl-') == 0) { var id = window.location.hash.slice('hl-'.length + 1); $('#' + id).addClass('highlight'); } } $(window).bind('hashchange', highlighter); highlighter(); $('table#domains').DataTable({ paging: false, columnDefs: [{ targets: 'no-order-and-search', orderable: false, searchable: false }], }); }); diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index fe4a854..13df1b8 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,70 +1,70 @@ class ApplicationController < ActionController::Base # Prevent CSRF attacks by raising an exception. # For APIs, you may want to use :null_session instead. protect_from_forgery with: :exception attr_writer :breadcrumb helper_method :admin? helper_method :dnssec? def admin? return false if params.key?('user') return false if current_user.nil? @admin_count ||= begin current_user .groups .where(name: WebDNS.settings[:admin_group]).count end @admin_count != 0 end def admin_only! return if admin? redirect_to root_path, alert: 'Admin only area!' end def dnssec? WebDNS.settings[:dnssec] end private def group @group ||= edit_group_scope.find(params[:group_id] || params[:id]) end def domain @domain ||= edit_domain_scope.find(params[:domain_id] || params[:id]) end def record - @record ||= record_scope.find(params[:record_id] || params[:id]) + @record ||= record_scope.find(params[:record_id] || params[:id] || params[:pk]) end def show_group_scope @show_group_scope ||= current_user.groups end def edit_group_scope @edit_group_scope ||= admin? ? Group.all : show_group_scope end def show_domain_scope @show_domain_scope ||= Domain.where(group: show_group_scope) end def edit_domain_scope @edit_domain_scope ||= admin? ? Domain.all : Domain.where(group: show_group_scope) end def record_scope @record_scope ||= domain.records end def notification Notification.instance if WebDNS.settings[:notifications] end end diff --git a/app/controllers/records_controller.rb b/app/controllers/records_controller.rb index 4baf816..7bd8565 100644 --- a/app/controllers/records_controller.rb +++ b/app/controllers/records_controller.rb @@ -1,79 +1,101 @@ class RecordsController < ApplicationController before_action :authenticate_user! before_action :domain, except: [:search] - before_action :record, only: [:edit, :update, :destroy] + before_action :editable_transform_params, only: [:editable] + before_action :record, only: [:edit, :update, :editable, :destroy] # GET /records/new def new @record = domain.records.build end # GET /records/1/edit def edit end # POST /records def create @record = domain.records.new(new_record_params) if @record.save notify_record(@record, :create) redirect_to domain, notice: 'Record was successfully created.' else flash[:alert] = 'There were some errors creating the record!' render :new end end # PATCH/PUT /records/1 def update if @record.update(edit_record_params) notify_record(@record, :update) redirect_to domain, notice: 'Record was successfully updated.' else render :edit end end + def editable + if @record.update(edit_record_params) + notify_record(@record, :update) + response = { + value: @record.read_attribute(@attr), + serial: @domain.soa(true).serial, + record: @record.as_json + } + + render json: response + else + render text: @record.errors[@attr].join(', '), status: 400 + end + end + # DELETE /records/1 def destroy @record.destroy notify_record(@record, :destroy) redirect_to domain, notice: 'Record was successfully destroyed.' end # GET /search def search @records = Record .where(domain: show_domain_scope) .includes(:domain) .search(params[:q]) # scope by domain @records = Record.smart_order(@records) end private + # Modify params to use standard Rails patterns + def editable_transform_params + @attr = params[:name] + params[:record] = { params[:name] => params[:value] } + end + def edit_record_params if @record.type == 'SOA' permitted = [:contact, :serial, :refresh, :retry, :expire, :nx] else permitted = [:name, :content, :ttl, :prio, :disabled] end params.require(:record).permit(*permitted).tap { |r| r[:drop_privileges] = true if not admin? } end def new_record_params params.require(:record).permit(:name, :content, :ttl, :type, :prio).tap { |r| r[:drop_privileges] = true if not admin? } end def notify_record(*args) notification.notify_record(current_user, *args) if WebDNS.settings[:notifications] end end diff --git a/app/helpers/records_helper.rb b/app/helpers/records_helper.rb index e0f974c..2fbd5fd 100644 --- a/app/helpers/records_helper.rb +++ b/app/helpers/records_helper.rb @@ -1,22 +1,40 @@ module RecordsHelper # Smart suffix for records # # On forward zones returns the zone name. # On reverse zones returns the zone name but also tries to infer the subnet. # # Returns a smart suffix string. def name_field_append(record) return ".#{record.domain.name}" if not record.domain.reverse? ".#{record.domain.name} (#{record.domain.subnet})" end # List of record types usually used for that domain type def record_types_for_domain(domain) return Record.reverse_records if domain.reverse? return Record.enum_records if domain.enum? Record.forward_records end + + def editable_record_attr(rec, attr) + return soa_content(rec) if rec.type == 'SOA' && attr == :content + return rec.read_attribute(attr) if rec.type == 'SOA' || !can_edit?(rec) + + link_to( + rec.read_attribute(attr), + "#edit-record-#{rec.id}-#{attr}", + class: 'editable', + data: { pk: rec.id, name: attr, type: 'text', url: editable_domain_records_path(rec.domain_id) } + ) + end + + def soa_content(rec) + SOA::SOA_FIELDS.map { |attr| + "#{rec.send(attr)}" + }.join(' ').html_safe + end end diff --git a/app/views/domains/show.html.erb b/app/views/domains/show.html.erb index 64000d7..e27f861 100644 --- a/app/views/domains/show.html.erb +++ b/app/views/domains/show.html.erb @@ -1,48 +1,52 @@ <% content_for :more_breadcrumbs do %>
  • <%= link_to_edit edit_domain_path(@domain) %>
  • <% end if admin? %> <%= render 'records/inline_form' %> <% Record.smart_order(@domain.records).each do |record| %> - - - - - - - - <% if record.classless_delegation? %> - - <% elsif can_edit?(record) %> - - - - <% else %> - + + + + + + <% if record.supports_prio? %> + + <% else %> + + <% end %> + + <% if record.classless_delegation? %> + + <% elsif can_edit?(record) %> + + + + <% else %> + <% end %>
    Records <%= 'Controls' if !@domain.slave? %>
    <%= record.name %><%= record.ttl %>IN<%= record.type %><%= record.supports_prio? ? record.prio : '' %><%= record.content %> - - <%= link_to_destroy [@domain, record], method: :delete, data: { confirm: 'Are you sure?' } %> - <% if record.disabled? %> - <%= link_to_enable enable_domain_record_path(@domain, record), method: :put %> - <% else %> - <%= link_to_disable disable_domain_record_path(@domain, record), method: :put %> - <% end %> - <%= link_to_edit edit_domain_record_path(@domain, record) %><%= link_to_destroy [@domain, record], method: :delete, data: { confirm: 'Are you sure?' } %> - - - <% end %> -
    <%= editable_record_attr(record, :name) %><%= record.ttl %>IN<%= record.type %><%= editable_record_attr(record, :prio) %> <%= editable_record_attr(record, :content) %> + + <%= link_to_destroy [@domain, record], method: :delete, data: { confirm: 'Are you sure?' } %> + <% if record.disabled? %> + <%= link_to_enable enable_domain_record_path(@domain, record), method: :put %> + <% else %> + <%= link_to_disable disable_domain_record_path(@domain, record), method: :put %> + <% end %> + <%= link_to_edit edit_domain_record_path(@domain, record) %><%= link_to_destroy [@domain, record], method: :delete, data: { confirm: 'Are you sure?' } %> + + + <% end %> +
    diff --git a/config/routes.rb b/config/routes.rb index 94031d9..bedbe4f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,52 +1,54 @@ Rails.application.routes.draw do # Override devise user removal devise_scope :users do delete :users, to: redirect('/') end devise_for :users root to: redirect('/domains') 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 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 # 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] resources :users, only: [] do get :orphans, to: 'users#orphans', on: :collection put :update_groups, to: 'users#update_groups', on: :collection end end # Private put 'private/replace_ds', to: 'private#replace_ds' put 'private/trigger_event', to: 'private#trigger_event' end