Merge branch 'New-forms-page' of https://github.com/bumi/tinyforms into New-forms-page
This commit is contained in:
commit
cd5483a9b6
4
Gemfile
4
Gemfile
@ -25,10 +25,12 @@ gem 'bootsnap', '>= 1.4.2', require: false
|
||||
gem 'lockbox'
|
||||
|
||||
gem 'aws-sdk-s3', require: false
|
||||
# gem 'airrecord'
|
||||
gem 'airrecord'
|
||||
gem 'google-api-client'
|
||||
gem 'rack-cors'
|
||||
gem 'sentry-raven'
|
||||
gem 'sequenced'
|
||||
gem 'sorcery'
|
||||
|
||||
group :development, :test do
|
||||
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
|
||||
|
25
Gemfile.lock
25
Gemfile.lock
@ -58,6 +58,9 @@ GEM
|
||||
zeitwerk (~> 2.2)
|
||||
addressable (2.7.0)
|
||||
public_suffix (>= 2.0.2, < 5.0)
|
||||
airrecord (1.0.5)
|
||||
faraday (>= 0.10, < 2.0)
|
||||
net-http-persistent (>= 2.9)
|
||||
aws-eventstream (1.0.3)
|
||||
aws-partitions (1.263.0)
|
||||
aws-sdk-core (3.89.1)
|
||||
@ -74,11 +77,13 @@ GEM
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sigv4 (1.1.0)
|
||||
aws-eventstream (~> 1.0, >= 1.0.2)
|
||||
bcrypt (3.1.13)
|
||||
bootsnap (1.4.6)
|
||||
msgpack (~> 1.0)
|
||||
builder (3.2.4)
|
||||
byebug (11.1.1)
|
||||
concurrent-ruby (1.1.6)
|
||||
connection_pool (2.2.2)
|
||||
crass (1.0.6)
|
||||
declarative (0.0.10)
|
||||
declarative-option (0.1.0)
|
||||
@ -133,10 +138,20 @@ GEM
|
||||
minitest (5.14.0)
|
||||
msgpack (1.3.3)
|
||||
multi_json (1.14.1)
|
||||
multi_xml (0.6.0)
|
||||
multipart-post (2.1.1)
|
||||
net-http-persistent (3.1.0)
|
||||
connection_pool (~> 2.2)
|
||||
nio4r (2.5.2)
|
||||
nokogiri (1.10.9)
|
||||
mini_portile2 (~> 2.4.0)
|
||||
oauth (0.5.4)
|
||||
oauth2 (1.4.4)
|
||||
faraday (>= 0.8, < 2.0)
|
||||
jwt (>= 1.0, < 3.0)
|
||||
multi_json (~> 1.3)
|
||||
multi_xml (~> 0.5)
|
||||
rack (>= 1.2, < 3)
|
||||
os (1.1.0)
|
||||
pg (1.2.3)
|
||||
public_suffix (4.0.4)
|
||||
@ -197,11 +212,18 @@ GEM
|
||||
semantic_range (2.3.0)
|
||||
sentry-raven (3.0.0)
|
||||
faraday (>= 1.0)
|
||||
sequenced (3.2.0)
|
||||
activerecord (>= 3.0)
|
||||
activesupport (>= 3.0)
|
||||
signet (0.14.0)
|
||||
addressable (~> 2.3)
|
||||
faraday (>= 0.17.3, < 2.0)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
sorcery (0.14.0)
|
||||
bcrypt (~> 3.1)
|
||||
oauth (~> 0.4, >= 0.4.4)
|
||||
oauth2 (~> 1.0, >= 0.8.0)
|
||||
spring (2.1.0)
|
||||
spring-watcher-listen (2.0.1)
|
||||
listen (>= 2.7, < 4.0)
|
||||
@ -237,6 +259,7 @@ PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
airrecord
|
||||
aws-sdk-s3
|
||||
bootsnap (>= 1.4.2)
|
||||
byebug
|
||||
@ -251,6 +274,8 @@ DEPENDENCIES
|
||||
rails
|
||||
sass-rails
|
||||
sentry-raven
|
||||
sequenced
|
||||
sorcery
|
||||
spring
|
||||
spring-watcher-listen
|
||||
sqlite3
|
||||
|
1
Procfile
1
Procfile
@ -1 +1,2 @@
|
||||
release: bundle exec rails db:migrate
|
||||
web: bundle exec puma -C config/puma.rb
|
||||
|
@ -1,2 +1,2 @@
|
||||
//= link_tree ../images
|
||||
//= link_directory ../stylesheets .css
|
||||
//= link application.css
|
||||
|
@ -1,18 +1,3 @@
|
||||
/*
|
||||
* 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, or any plugin's
|
||||
* vendor/assets/stylesheets directory 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 other CSS/SCSS
|
||||
* files in this directory. Styles in this file should be added after the last require_* statement.
|
||||
* It is generally better to create a new file per style scope.
|
||||
*
|
||||
*= require_tree .
|
||||
*= require_self
|
||||
*/
|
||||
@import "bulma/sass/utilities/initial-variables";
|
||||
@import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Lobster&family=Comfortaa:wght@400;500;600;700&display=swap');
|
||||
@import url('https://use.fontawesome.com/releases/v5.12.0/css/all.css');
|
||||
@ -40,6 +25,8 @@ $light: #fff;
|
||||
|
||||
@import 'bulma/bulma';
|
||||
|
||||
@import './demo';
|
||||
|
||||
.is-font-logo {
|
||||
font-family: 'Lobster', cursive;
|
||||
}
|
||||
@ -60,4 +47,9 @@ $light: #fff;
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.button.google {
|
||||
background: rgb(225, 98, 89);
|
||||
color: white;
|
||||
}
|
16
app/assets/stylesheets/demo.css.scss
Normal file
16
app/assets/stylesheets/demo.css.scss
Normal file
@ -0,0 +1,16 @@
|
||||
section.demo {
|
||||
height: 90vh;
|
||||
min-height: 600px;
|
||||
}
|
||||
.demo-form {
|
||||
border: 1px solid $grey-light;
|
||||
padding: 1em;
|
||||
height: 100%;
|
||||
}
|
||||
section.demo .container, section.demo .columns, section.demo .column {
|
||||
height: 100%;
|
||||
}
|
||||
#demo-success {
|
||||
text-align: center;
|
||||
padding-top: 1em;
|
||||
}
|
@ -2,7 +2,7 @@ class FileUploadsController < ApplicationController
|
||||
def show
|
||||
@form = Form.find_by!(token: params[:form_id])
|
||||
@submission = @form.submissions.find(params[:submission_id])
|
||||
@file_upload = @submission.files_attachments.find(params[:id])
|
||||
@file_upload = @submission.files_attachments.find_by!(token: params[:id])
|
||||
redirect_to url_for(@file_upload)
|
||||
end
|
||||
end
|
||||
|
34
app/controllers/oauths_controller.rb
Normal file
34
app/controllers/oauths_controller.rb
Normal file
@ -0,0 +1,34 @@
|
||||
class OauthsController < ApplicationController
|
||||
|
||||
# Sends the user on a trip to the provider,
|
||||
# and after authorizing there back to the callback url.
|
||||
def oauth
|
||||
login_at(params[:provider])
|
||||
end
|
||||
|
||||
def callback
|
||||
provider = params[:provider]
|
||||
if @user = login_from(provider)
|
||||
redirect_to root_path, :notice => "Logged in from #{provider.titleize}!"
|
||||
else
|
||||
begin
|
||||
@user = create_from(provider)
|
||||
if authentication = @user.authentications.find_by(provider: provider)
|
||||
authentication.update({
|
||||
access_token: @access_token.token,
|
||||
refresh_token: @access_token.refresh_token,
|
||||
expires_at: Time.at(@access_token.expires_at)
|
||||
})
|
||||
end
|
||||
|
||||
reset_session
|
||||
auto_login(@user)
|
||||
redirect_to root_path, :notice => "Logged in from #{provider.titleize}!"
|
||||
rescue
|
||||
Rails.logger.error("Failed to login from #{provider}")
|
||||
redirect_to root_path, :alert => "Failed to login from #{provider.titleize}!"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
@ -1,29 +1,7 @@
|
||||
require 'google/apis/oauth2_v2'
|
||||
class SessionsController < ApplicationController
|
||||
|
||||
def new
|
||||
reset_session
|
||||
redirect_to auth_client.authorization_uri.to_s
|
||||
end
|
||||
|
||||
def auth
|
||||
reset_session
|
||||
if params[:error]
|
||||
flash[:error] = 'Login failed'
|
||||
redirect_to root_url
|
||||
else
|
||||
auth_client.code = params[:code]
|
||||
auth_client.fetch_access_token!
|
||||
|
||||
@user, @authentication = User.find_by_oauth_info(auth_client)
|
||||
if @user.persisted? && @authentication.persisted?
|
||||
session[:user_id] = @user.id.to_s
|
||||
redirect_to forms_url
|
||||
else
|
||||
flash[:error] = 'Login failed'
|
||||
redirect_to root_url
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@ -31,18 +9,4 @@ class SessionsController < ApplicationController
|
||||
redirect_to root_url
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def auth_client
|
||||
@auth_client ||= CLIENT_SECRETS.to_authorization.tap do |c|
|
||||
c.update!(
|
||||
scope: 'https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/spreadsheets',
|
||||
redirect_uri: auth_url,
|
||||
additional_parameters: {
|
||||
'access_type' => 'offline', # offline access
|
||||
'include_granted_scopes' => 'true' # incremental auth
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -14,7 +14,7 @@ class SubmissionsController < ApplicationController
|
||||
respond_to do |format|
|
||||
if @submission.save
|
||||
format.html { redirect_to(@form.thank_you_url) if @form.thank_you_url.present? }
|
||||
format.json { render(json: { success: true, data: @submission.data }) }
|
||||
format.json { render(json: { success: true, submission: @submission.data }) }
|
||||
else
|
||||
format.html { redirect_to(@form.thank_you_url) if @form.thank_you_url.present? }
|
||||
format.json { render(json: { error: @submission.errors }, status: 422) }
|
||||
|
20
app/javascript/demo.js
Normal file
20
app/javascript/demo.js
Normal file
@ -0,0 +1,20 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var demoForm = document.getElementById('demo-form');
|
||||
if (!demoForm) {
|
||||
return;
|
||||
}
|
||||
demoForm.addEventListener('submit', function(e) {
|
||||
var submitButton = document.getElementById('demo-submit');
|
||||
submitButton.innerText = 'loading...';
|
||||
submitButton.disabled = 'true'
|
||||
});
|
||||
|
||||
demoForm.addEventListener('tinyforms:submitted', function(e) {
|
||||
var name = document.getElementById('demo-submission-name');
|
||||
var demoFields = document.getElementById('demo-fields');
|
||||
var demoSucess = document.getElementById('demo-success');
|
||||
demoFields.style.display = 'none';
|
||||
demoSucess.style.display = 'block';
|
||||
name.innerText = e.detail.submission.Name;
|
||||
});
|
||||
});
|
@ -9,6 +9,8 @@ require("@rails/activestorage").start()
|
||||
require("channels")
|
||||
|
||||
require('burger_menu');
|
||||
require('tinyforms');
|
||||
require('demo');
|
||||
|
||||
// Uncomment to copy all static images under ../images to the output folder and reference
|
||||
// them with the image_pack_tag helper in views (e.g <%= image_pack_tag 'rails.png' %>)
|
||||
|
51
app/javascript/tinyforms.js
Normal file
51
app/javascript/tinyforms.js
Normal file
@ -0,0 +1,51 @@
|
||||
window.tinyforms = {
|
||||
init: function() {
|
||||
var forms = document.querySelectorAll('form[data-tinyforms]');
|
||||
forms.forEach(function(form) {
|
||||
form.addEventListener('submit', tinyforms.onSubmit);
|
||||
});
|
||||
},
|
||||
|
||||
submitForm: function(form) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var action = form.getAttribute('action');
|
||||
var formData = new FormData(form);
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', action, true);
|
||||
xhr.setRequestHeader("Accept", "application/json");
|
||||
xhr.onload = function () {
|
||||
var response;
|
||||
try {
|
||||
response = JSON.parse(xhr.responseText);
|
||||
} catch (e) {
|
||||
response = xhr.responseText;
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(response);
|
||||
}
|
||||
};
|
||||
xhr.onerror = function() {
|
||||
reject(xhr.responseText)
|
||||
};
|
||||
xhr.send(formData);
|
||||
});
|
||||
},
|
||||
|
||||
onSubmit: function(e) {
|
||||
e.preventDefault();
|
||||
return tinyforms.submitForm(this)
|
||||
.then((response) => {
|
||||
this.dispatchEvent(new CustomEvent('tinyforms:submitted', {detail: { submission: response.submission, response: response }}));
|
||||
})
|
||||
.catch((response) => {
|
||||
this.dispatchEvent(new CustomEvent('tinyforms:error', {detail: { submission: response.submission, response: response }}));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
tinyforms.init();
|
||||
});
|
@ -1,6 +1,8 @@
|
||||
class Authentication < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
scope :for, -> (provider) { where(provider: provider) }
|
||||
|
||||
encrypts :access_token
|
||||
encrypts :refresh_token
|
||||
|
||||
@ -9,6 +11,7 @@ class Authentication < ApplicationRecord
|
||||
end
|
||||
|
||||
def google_authorization
|
||||
return nil unless provider == 'google'
|
||||
@google_authorization ||= CLIENT_SECRETS.to_authorization.tap do |c|
|
||||
c.access_token = self.access_token
|
||||
c.refresh_token = self.refresh_token
|
||||
|
@ -1,23 +1,29 @@
|
||||
require 'google/apis/sheets_v4'
|
||||
class Form < ApplicationRecord
|
||||
# Hash to translate an index to a A1 notation. e.g. 1 => 'B', 27 => 'AA'
|
||||
COLUMN_INDEX_TO_LETTER = Hash.new {|hash,key| hash[key] = hash[key - 1].next }.merge({0 => "A"})
|
||||
|
||||
belongs_to :user
|
||||
has_many :submissions, dependent: :destroy
|
||||
|
||||
before_validation :insert_defaults, on: :create
|
||||
after_create :create_spreadsheet
|
||||
|
||||
has_secure_token
|
||||
|
||||
encrypts :airtable_api_key
|
||||
encrypts :airtable_app_key
|
||||
|
||||
validates_presence_of :title
|
||||
validates_inclusion_of :backend_name, in: ['google_sheets', 'airtable']
|
||||
# Airtable validations
|
||||
validates_presence_of :airtable_api_key, if: :airtable?
|
||||
validates_presence_of :airtable_app_key, if: :airtable?
|
||||
validates_presence_of :airtable_table, if: :airtable?
|
||||
|
||||
# TODO: use counter_cache option on association
|
||||
def submissions_count
|
||||
submissions.count
|
||||
@submissions_count ||= submissions.count
|
||||
end
|
||||
|
||||
def last_submission_date
|
||||
submissions.order(created_at: :desc).first&.created_at
|
||||
@last_submission_date ||= submissions.order(created_at: :desc).first&.created_at
|
||||
end
|
||||
|
||||
def deactivate!(reason = nil)
|
||||
@ -28,63 +34,39 @@ class Form < ApplicationRecord
|
||||
self.user.active?
|
||||
end
|
||||
|
||||
def google_spreadsheet_url
|
||||
"https://docs.google.com/spreadsheets/d/#{google_spreadsheet_id}/edit" if google_spreadsheet_id.present?
|
||||
def airtable?
|
||||
backend_name == 'airtable'
|
||||
end
|
||||
|
||||
def google
|
||||
backend_name == 'google'
|
||||
end
|
||||
|
||||
def backend
|
||||
@backend ||= SpreadsheetBackends.const_get(backend_name.camelize).new(self)
|
||||
end
|
||||
|
||||
def spreadsheet_url
|
||||
backend.url
|
||||
end
|
||||
|
||||
def create_spreadsheet
|
||||
sheets = Google::Apis::SheetsV4::SheetsService.new
|
||||
sheets.authorization = user.google_authorization
|
||||
create_object = Google::Apis::SheetsV4::Spreadsheet.new(properties: { title: title})
|
||||
spreadsheet = sheets.create_spreadsheet(create_object)
|
||||
update(google_spreadsheet_id: spreadsheet.spreadsheet_id)
|
||||
backend.create
|
||||
end
|
||||
|
||||
def spreadsheet_service
|
||||
@spreadsheet_service ||= Google::Apis::SheetsV4::SheetsService.new.tap do |s|
|
||||
s.authorization = user.google_authorization
|
||||
end
|
||||
def append_to_spreadsheet(data)
|
||||
backend.append(data)
|
||||
end
|
||||
|
||||
def header_values
|
||||
@header_values ||= begin
|
||||
values = spreadsheet_service.get_spreadsheet_values(google_spreadsheet_id, 'A1:An').values
|
||||
# if there are no headers yet, return an empty array
|
||||
if values
|
||||
values[0].map(&:strip)
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def append(data)
|
||||
data = data.with_indifferent_access
|
||||
check_spreadsheed_headers!(data)
|
||||
|
||||
values = header_values.map { |key| data[key] }
|
||||
range = "A1:A#{COLUMN_INDEX_TO_LETTER[values.length]}1"
|
||||
value_range = Google::Apis::SheetsV4::ValueRange.new(values: [values], major_dimension: 'ROWS')
|
||||
|
||||
spreadsheet_service.append_spreadsheet_value(google_spreadsheet_id, range, value_range, value_input_option: 'USER_ENTERED')
|
||||
end
|
||||
|
||||
def check_spreadsheed_headers!(data)
|
||||
missing_headers = data.keys.map { |k| k.to_s.strip } - header_values
|
||||
append_missing_headers(missing_headers) unless missing_headers.empty?
|
||||
end
|
||||
|
||||
def append_missing_headers(missing_headers)
|
||||
start_column = COLUMN_INDEX_TO_LETTER[header_values.length]
|
||||
end_column = COLUMN_INDEX_TO_LETTER[header_values.length + missing_headers.length]
|
||||
range = "#{start_column}1:#{end_column}1"
|
||||
value_range = Google::Apis::SheetsV4::ValueRange.new(values: [missing_headers], major_dimension: 'ROWS')
|
||||
spreadsheet_service.update_spreadsheet_value(google_spreadsheet_id, range, value_range, value_input_option: 'USER_ENTERED')
|
||||
@header_values = nil # reset header values to refresh memoization on next access
|
||||
def spreadsheet_headers
|
||||
backend.headers
|
||||
end
|
||||
|
||||
def to_param
|
||||
token
|
||||
end
|
||||
|
||||
def insert_defaults
|
||||
self.backend_name ||= airtable_app_key.present? ? 'airtable' : 'google_sheets'
|
||||
end
|
||||
end
|
||||
|
@ -2,6 +2,8 @@ class Submission < ApplicationRecord
|
||||
belongs_to :form
|
||||
has_many_attached :files
|
||||
|
||||
acts_as_sequenced scope: :form_id
|
||||
|
||||
validates_presence_of :data, if: :appended_at?
|
||||
|
||||
def process_data(submitted_data)
|
||||
@ -21,6 +23,10 @@ class Submission < ApplicationRecord
|
||||
JSON.dump(value)
|
||||
when 'tinyforms_now'
|
||||
Time.now.utc.to_formatted_s(:rfc822)
|
||||
when 'tinyforms_token'
|
||||
form.token
|
||||
when 'tinyforms_id'
|
||||
sequential_id
|
||||
when ActionDispatch::Http::UploadedFile
|
||||
# manually create the ActiveStorage attachment because we need the ID of the Attachment to create the URL
|
||||
# first the file needs to be uplaoded then we can create an Attachment
|
||||
@ -31,14 +37,14 @@ class Submission < ApplicationRecord
|
||||
attachment = ActiveStorage::Attachment.new(record: self, name: 'files', blob: create_one.blob)
|
||||
attachment.save
|
||||
# return the URL that we use to show in the Spreadsheet
|
||||
Rails.application.routes.url_helpers.file_upload_url(form_id: form, submission_id: self, id: attachment.id, host: DEFAULT_HOST)
|
||||
Rails.application.routes.url_helpers.file_upload_url(form_id: form, submission_id: self, id: attachment.token, host: DEFAULT_HOST)
|
||||
else
|
||||
value.to_s
|
||||
end
|
||||
end
|
||||
|
||||
def append_to_spreadsheet
|
||||
result = form.append(data)
|
||||
update_column(:appended_at, Time.current) if result.updates.updated_rows > 0
|
||||
form.append_to_spreadsheet(data) &&
|
||||
update_column(:appended_at, Time.current)
|
||||
end
|
||||
end
|
||||
|
@ -1,40 +1,18 @@
|
||||
class User < ApplicationRecord
|
||||
authenticates_with_sorcery!
|
||||
has_many :authentications, dependent: :destroy
|
||||
has_many :forms, dependent: :destroy
|
||||
|
||||
def self.find_by_oauth_info(auth_client)
|
||||
oauth = Google::Apis::Oauth2V2::Oauth2Service.new
|
||||
oauth.authorization = auth_client
|
||||
user_info = oauth.get_userinfo
|
||||
|
||||
if user = User.find_by(google_id: user_info.id)
|
||||
authentication = user.authentications.last
|
||||
authentication.access_token = auth_client.access_token if auth_client.access_token.present?
|
||||
authentication.refresh_token = auth_client.refresh_token if auth_client.refresh_token.present?
|
||||
authentication.expires_at = Time.at(auth_client.expires_at) if auth_client.expires_at.present?
|
||||
authentication.save
|
||||
return user, authentication
|
||||
else
|
||||
user = User.create(name: user_info.name, email: user_info.email, google_id: user_info.id)
|
||||
authentication = user.authentications.create(
|
||||
access_token: auth_client.access_token,
|
||||
refresh_token: auth_client.refresh_token,
|
||||
expires_at: Time.at(auth_client.expires_at)
|
||||
)
|
||||
return user, authentication
|
||||
end
|
||||
end
|
||||
|
||||
def deactivate!(reason = nil)
|
||||
# currently we only use deactivate if we get an authentication exception appending data to a spreadsheet
|
||||
authentications.last&.update(expires_at: Time.current)
|
||||
end
|
||||
|
||||
def active?
|
||||
authentications.last.present? && !authentications.last.expired?
|
||||
authentications.any? { |a| !a.expired? }
|
||||
end
|
||||
|
||||
def google_authorization
|
||||
authentications.last.google_authorization
|
||||
authentications.for(:google).last.google_authorization
|
||||
end
|
||||
end
|
||||
|
@ -25,7 +25,7 @@
|
||||
</div>
|
||||
<div class="media-right has-text-right is-size-4">
|
||||
<figure class="image is-48x48 ">
|
||||
<%= link_to form.google_spreadsheet_url, {class: "has-text-success", target: "__blank"} do %>
|
||||
<%= link_to form.spreadsheet_url, {class: "has-text-success", target: "__blank"} do %>
|
||||
<i class="far fa-file-excel"></i>
|
||||
<% end %>
|
||||
</figure>
|
||||
|
@ -3,12 +3,12 @@
|
||||
<%= submission_url(@form) %>
|
||||
</p>
|
||||
<p>
|
||||
<%= link_to 'Google spreadsheet', @form.google_spreadsheet_url %>
|
||||
<%= link_to 'Google spreadsheet', @form.spreadsheet_url %>
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<% @form.header_values.each do |value| %>
|
||||
<% @form.spreadsheet_headers.each do |value| %>
|
||||
<th><%= value %></th>
|
||||
<% end %>
|
||||
</tr>
|
||||
@ -16,7 +16,7 @@
|
||||
<tbody>
|
||||
<% @submissions.each do |submission| %>
|
||||
<tr>
|
||||
<% @form.header_values.each do |column| %>
|
||||
<% @form.spreadsheet_headers.each do |column| %>
|
||||
<td><%= submission.data[column] %></td>
|
||||
<% end %>
|
||||
</tr>
|
||||
|
88
app/views/home/demo.html.erb
Normal file
88
app/views/home/demo.html.erb
Normal file
@ -0,0 +1,88 @@
|
||||
<section class="section demo">
|
||||
<div class="container content">
|
||||
<div class="columns">
|
||||
<div class="column is-one-third">
|
||||
<form class="form demo-form" id="demo-form" action="<%= submission_url(DEMO_FORM) %>" method="POST" enctype="multipart/form-data" data-tinyforms="true">
|
||||
<input type="hidden" name="ID" value="tinyforms_id">
|
||||
<h3>Demo</h3>
|
||||
<p>
|
||||
This short form is connected to the embedded <%= link_to 'document', DEMO_FORM.spreadsheet_url %> on the right. <br>
|
||||
Submit the form and see the update of the document in realtime.
|
||||
</p>
|
||||
|
||||
<div id="demo-success" style="display:none;">
|
||||
<p class="is-size-4 has-text-success">
|
||||
<span id="demo-submission-name"></span>, thanks for your submission!
|
||||
</p>
|
||||
<p>See your entry in the spreadsheet?!</p>
|
||||
<p>
|
||||
<%= link_to "Create your form now!", signup_url, class: 'button' %>
|
||||
<br>
|
||||
or got <%= link_to 'further questions?', contact_url %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="demo-fields">
|
||||
<div class="field">
|
||||
<label class="label">What's your name?*</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="Name" required="true" placeholder="Your name">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Where are you from?*</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select name="Continent">
|
||||
<option value="Africa">Africa</option>
|
||||
<option value="Antartica">Antartica</option>
|
||||
<option value="Asia">Asia</option>
|
||||
<option value="Australia">Australia</option>
|
||||
<option value="Europe">Europe</option>
|
||||
<option value="North America">North America</option>
|
||||
<option value="South America">South America</option>
|
||||
<option value="Australia">Australia</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Do you like Pizza with Pinapples?*</label>
|
||||
<div class="control">
|
||||
<label class="radio">
|
||||
<input type="radio" name="Pinapples Pizza?" required value="yes">
|
||||
Yes
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" name="Pinapples Pizza?" required value="no">
|
||||
Hell, no!
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">What's your favorit cat picture?</label>
|
||||
<div class="control">
|
||||
<input class="input" type="file" name="Cat Picture">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button class="button is-link" id="demo-submit">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
<iframe id="demo-sheet" style="width:100%;height:100%;" src="https://docs.google.com/spreadsheets/d/<%= DEMO_FORM.google_spreadsheet_id %>/edit?usp=sharing&nocache=<%= Time.now.to_i %>#gid=0&range=<%= DEMO_FORM.submissions.count + 1 %>:<%= DEMO_FORM.submissions.count + 2 %>"></iframe>
|
||||
<p class="has-text-grey has-text-centered">Document not loading? <%= link_to 'Open it here', DEMO_FORM.spreadsheet_url %>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
29
app/views/layouts/_header.html.erb
Normal file
29
app/views/layouts/_header.html.erb
Normal file
@ -0,0 +1,29 @@
|
||||
<nav class="navbar" role="navigation" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand">
|
||||
<a class="navbar-item is-size-4 has-text-black has-text-weight-bold is-font-logo" href="/">
|
||||
TinyForms
|
||||
</a>
|
||||
|
||||
<a role="button" class="navbar-burger burger" aria-label="menu" aria-expanded="false"
|
||||
data-target="navbar-menu">
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="navbar-menu" class="navbar-menu">
|
||||
<div class="navbar-end">
|
||||
<%= link_to 'Help', help_url, class: 'navbar-item' %>
|
||||
<div class="navbar-item">
|
||||
<% if !logged_in? -%>
|
||||
<%= link_to "Login", login_url, { :class => "button is-primary"} %>
|
||||
<% else -%>
|
||||
<%= link_to "Logout", logout_url, { :class => "button is-light"} %>
|
||||
<% end -%>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
@ -11,49 +11,12 @@
|
||||
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Wrapper -->
|
||||
<div id="wrapper" class="has-text-centered-mobile">
|
||||
<body class="has-text-centered-mobile">
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="hero is-medium is-light">
|
||||
<div class="hero-head">
|
||||
<div class="container">
|
||||
<nav class="navbar" role="navigation" aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
<a class="navbar-item is-size-4 has-text-black has-text-weight-bold is-font-logo" href="/">
|
||||
TinyForms
|
||||
</a>
|
||||
|
||||
<a role="button" class="navbar-burger burger" aria-label="menu" aria-expanded="false"
|
||||
data-target="navbar">
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="navbar" class="navbar-menu">
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item">
|
||||
<div class="buttons">
|
||||
<% if !logged_in? -%>
|
||||
<%= link_to "Login", login_url, { :class => "button is-primary"} %>
|
||||
<% else -%>
|
||||
<%= link_to "Logout", logout_url, { :class => "button is-light"} %>
|
||||
<% end -%>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main>
|
||||
<%= yield %>
|
||||
</main>
|
||||
</div>
|
||||
<%= render 'layouts/header' %>
|
||||
<main>
|
||||
<%= yield %>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
22
app/views/sessions/new.html.erb
Normal file
22
app/views/sessions/new.html.erb
Normal file
@ -0,0 +1,22 @@
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="container has-text-centered">
|
||||
<h1 class="title has-text-centered">
|
||||
Login
|
||||
</h1>
|
||||
<%= link_to auth_at_provider_url(provider: 'google'), class: 'button google' do %>
|
||||
<svg viewBox="0 0 15 15" class="googleLogo" style="width: 14px; height: 14px; display: block; fill: currentcolor; flex-shrink: 0; backface-visibility: hidden; margin-right: 6px;"><path d="M 7.28571 6.4125L 7.28571 9L 11.3929 9C 11.2143 10.0875 10.1429 12.225 7.28571 12.225C 4.78571 12.225 2.78571 10.0875 2.78571 7.5C 2.78571 4.9125 4.82143 2.775 7.28571 2.775C 8.71429 2.775 9.64286 3.4125 10.1786 3.9375L 12.1429 1.9875C 10.8929 0.75 9.25 0 7.28571 0C 3.25 0 0 3.3375 0 7.5C 0 11.6625 3.25 15 7.28571 15C 11.5 15 14.25 11.9625 14.25 7.6875C 14.25 7.2 14.2143 6.825 14.1429 6.45L 7.28571 6.45L 7.28571 6.4125Z"></path></svg>
|
||||
Login with Google
|
||||
<% end %>
|
||||
<hr>
|
||||
<p>
|
||||
As tinyforms builds on Google Sheets.<br>
|
||||
Simply login with your Google account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
</div>
|
||||
</div>
|
@ -15,6 +15,17 @@ module Tinyform
|
||||
# Application configuration can go into files in config/initializers
|
||||
# -- all .rb files in that directory are automatically loaded after loading
|
||||
# the framework and any gems in your application.
|
||||
config.generators do |generate|
|
||||
generate.helper false
|
||||
generate.javascript_engine false
|
||||
generate.request_specs false
|
||||
generate.routing_specs false
|
||||
generate.stylesheets false
|
||||
generate.view_specs false
|
||||
end
|
||||
|
||||
config.autoload_paths << Rails.root.join('lib')
|
||||
|
||||
config.middleware.insert_before 0, Rack::Cors do
|
||||
allow do
|
||||
origins '*'
|
||||
|
3
config/initializers/attachment_tokens.rb
Normal file
3
config/initializers/attachment_tokens.rb
Normal file
@ -0,0 +1,3 @@
|
||||
Rails.configuration.to_prepare do
|
||||
ActiveStorage::Attachment.send(:has_secure_token)
|
||||
end
|
1
config/initializers/demo.rb
Normal file
1
config/initializers/demo.rb
Normal file
@ -0,0 +1 @@
|
||||
DEMO_FORM = Form.find_by(id: ENV['DEMO_FORM_ID'])
|
532
config/initializers/sorcery.rb
Normal file
532
config/initializers/sorcery.rb
Normal file
@ -0,0 +1,532 @@
|
||||
# The first thing you need to configure is which modules you need in your app.
|
||||
# The default is nothing which will include only core features (password encryption, login/logout).
|
||||
#
|
||||
# Available submodules are: :user_activation, :http_basic_auth, :remember_me,
|
||||
# :reset_password, :session_timeout, :brute_force_protection, :activity_logging,
|
||||
# :magic_login, :external
|
||||
Rails.application.config.sorcery.submodules = [:reset_password, :external, :magic_login]
|
||||
|
||||
# Here you can configure each submodule's features.
|
||||
Rails.application.config.sorcery.configure do |config|
|
||||
# -- core --
|
||||
# What controller action to call for non-authenticated users. You can also
|
||||
# override the 'not_authenticated' method of course.
|
||||
# Default: `:not_authenticated`
|
||||
#
|
||||
# config.not_authenticated_action =
|
||||
|
||||
# When a non logged-in user tries to enter a page that requires login, save
|
||||
# the URL he wants to reach, and send him there after login, using 'redirect_back_or_to'.
|
||||
# Default: `true`
|
||||
#
|
||||
# config.save_return_to_url =
|
||||
|
||||
# Set domain option for cookies; Useful for remember_me submodule.
|
||||
# Default: `nil`
|
||||
#
|
||||
# config.cookie_domain =
|
||||
|
||||
# Allow the remember_me cookie to be set through AJAX
|
||||
# Default: `true`
|
||||
#
|
||||
# config.remember_me_httponly =
|
||||
|
||||
# Set token randomness. (e.g. user activation tokens)
|
||||
# The length of the result string is about 4/3 of `token_randomness`.
|
||||
# Default: `15`
|
||||
#
|
||||
# config.token_randomness =
|
||||
|
||||
# -- session timeout --
|
||||
# How long in seconds to keep the session alive.
|
||||
# Default: `3600`
|
||||
#
|
||||
# config.session_timeout =
|
||||
|
||||
# Use the last action as the beginning of session timeout.
|
||||
# Default: `false`
|
||||
#
|
||||
# config.session_timeout_from_last_action =
|
||||
|
||||
# Invalidate active sessions. Requires an `invalidate_sessions_before` timestamp column
|
||||
# Default: `false`
|
||||
#
|
||||
# config.session_timeout_invalidate_active_sessions_enabled =
|
||||
|
||||
# -- http_basic_auth --
|
||||
# What realm to display for which controller name. For example {"My App" => "Application"}
|
||||
# Default: `{"application" => "Application"}`
|
||||
#
|
||||
# config.controller_to_realm_map =
|
||||
|
||||
# -- activity logging --
|
||||
# Will register the time of last user login, every login.
|
||||
# Default: `true`
|
||||
#
|
||||
# config.register_login_time =
|
||||
|
||||
# Will register the time of last user logout, every logout.
|
||||
# Default: `true`
|
||||
#
|
||||
# config.register_logout_time =
|
||||
|
||||
# Will register the time of last user action, every action.
|
||||
# Default: `true`
|
||||
#
|
||||
# config.register_last_activity_time =
|
||||
|
||||
# -- external --
|
||||
# What providers are supported by this app
|
||||
# i.e. [:twitter, :facebook, :github, :linkedin, :xing, :google, :liveid, :salesforce, :slack, :line].
|
||||
# Default: `[]`
|
||||
#
|
||||
config.external_providers = [:google]
|
||||
|
||||
# You can change it by your local ca_file. i.e. '/etc/pki/tls/certs/ca-bundle.crt'
|
||||
# Path to ca_file. By default use a internal ca-bundle.crt.
|
||||
# Default: `'path/to/ca_file'`
|
||||
#
|
||||
# config.ca_file =
|
||||
|
||||
# config.linkedin.key = ""
|
||||
# config.linkedin.secret = ""
|
||||
# config.linkedin.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=linkedin"
|
||||
# config.linkedin.user_info_mapping = {first_name: "firstName", last_name: "lastName"}
|
||||
# config.linkedin.scope = "r_basicprofile"
|
||||
#
|
||||
#
|
||||
# For information about XING API:
|
||||
# - user info fields go to https://dev.xing.com/docs/get/users/me
|
||||
#
|
||||
# config.xing.key = ""
|
||||
# config.xing.secret = ""
|
||||
# config.xing.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=xing"
|
||||
# config.xing.user_info_mapping = {first_name: "first_name", last_name: "last_name"}
|
||||
#
|
||||
#
|
||||
# Twitter will not accept any requests nor redirect uri containing localhost,
|
||||
# Make sure you use 0.0.0.0:3000 to access your app in development
|
||||
#
|
||||
# config.twitter.key = ""
|
||||
# config.twitter.secret = ""
|
||||
# config.twitter.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=twitter"
|
||||
# config.twitter.user_info_mapping = {:email => "screen_name"}
|
||||
#
|
||||
# config.facebook.key = ""
|
||||
# config.facebook.secret = ""
|
||||
# config.facebook.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=facebook"
|
||||
# config.facebook.user_info_path = "me?fields=email"
|
||||
# config.facebook.user_info_mapping = {:email => "email"}
|
||||
# config.facebook.access_permissions = ["email"]
|
||||
# config.facebook.display = "page"
|
||||
# config.facebook.api_version = "v2.3"
|
||||
# config.facebook.parse = :json
|
||||
#
|
||||
# config.instagram.key = ""
|
||||
# config.instagram.secret = ""
|
||||
# config.instagram.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=instagram"
|
||||
# config.instagram.user_info_mapping = {:email => "username"}
|
||||
# config.instagram.access_permissions = ["basic", "public_content", "follower_list", "comments", "relationships", "likes"]
|
||||
#
|
||||
# config.github.key = ""
|
||||
# config.github.secret = ""
|
||||
# config.github.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=github"
|
||||
# config.github.user_info_mapping = {:email => "name"}
|
||||
# config.github.scope = ""
|
||||
#
|
||||
# config.paypal.key = ""
|
||||
# config.paypal.secret = ""
|
||||
# config.paypal.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=paypal"
|
||||
# config.paypal.user_info_mapping = {:email => "email"}
|
||||
#
|
||||
# config.wechat.key = ""
|
||||
# config.wechat.secret = ""
|
||||
# config.wechat.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=wechat"
|
||||
#
|
||||
# For Auth0, site is required and should match the domain provided by Auth0.
|
||||
#
|
||||
# config.auth0.key = ""
|
||||
# config.auth0.secret = ""
|
||||
# config.auth0.callback_url = "https://0.0.0.0:3000/oauth/callback?provider=auth0"
|
||||
# config.auth0.site = "https://example.auth0.com"
|
||||
#
|
||||
config.google.key = ENV['GOOGLE_CLIENT_ID']
|
||||
config.google.secret = ENV['GOOGLE_CLIENT_SECRET']
|
||||
config.google.callback_url = (ENV['GOOGLE_AUTH_CALLBACK_URL'] || "http://localhost:3000/oauth/callback?provider=google")
|
||||
config.google.user_info_mapping = {:email => "email", :name => "name", :google_id => "id"}
|
||||
config.google.scope = "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/spreadsheets"
|
||||
config.google.auth_url = '/o/oauth2/auth?access_type=offline&include_granted_scopes=true'
|
||||
#
|
||||
# For Microsoft Graph, the key will be your App ID, and the secret will be your app password/public key.
|
||||
# The callback URL "can't contain a query string or invalid special characters"
|
||||
# See: https://docs.microsoft.com/en-us/azure/active-directory/active-directory-v2-limitations#restrictions-on-redirect-uris
|
||||
# More information at https://graph.microsoft.io/en-us/docs
|
||||
#
|
||||
# config.microsoft.key = ""
|
||||
# config.microsoft.secret = ""
|
||||
# config.microsoft.callback_url = "http://0.0.0.0:3000/oauth/callback/microsoft"
|
||||
# config.microsoft.user_info_mapping = {:email => "userPrincipalName", :username => "displayName"}
|
||||
# config.microsoft.scope = "openid email https://graph.microsoft.com/User.Read"
|
||||
#
|
||||
# config.vk.key = ""
|
||||
# config.vk.secret = ""
|
||||
# config.vk.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=vk"
|
||||
# config.vk.user_info_mapping = {:login => "domain", :name => "full_name"}
|
||||
# config.vk.api_version = "5.71"
|
||||
#
|
||||
# config.slack.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=slack"
|
||||
# config.slack.key = ''
|
||||
# config.slack.secret = ''
|
||||
# config.slack.user_info_mapping = {email: 'email'}
|
||||
#
|
||||
# To use liveid in development mode you have to replace mydomain.com with
|
||||
# a valid domain even in development. To use a valid domain in development
|
||||
# simply add your domain in your /etc/hosts file in front of 127.0.0.1
|
||||
#
|
||||
# config.liveid.key = ""
|
||||
# config.liveid.secret = ""
|
||||
# config.liveid.callback_url = "http://mydomain.com:3000/oauth/callback?provider=liveid"
|
||||
# config.liveid.user_info_mapping = {:username => "name"}
|
||||
|
||||
# For information about JIRA API:
|
||||
# https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+Example+-+OAuth+authentication
|
||||
# To obtain the consumer key and the public key you can use the jira-ruby gem https://github.com/sumoheavy/jira-ruby
|
||||
# or run openssl req -x509 -nodes -newkey rsa:1024 -sha1 -keyout rsakey.pem -out rsacert.pem to obtain the public key
|
||||
# Make sure you have configured the application link properly
|
||||
|
||||
# config.jira.key = "1234567"
|
||||
# config.jira.secret = "jiraTest"
|
||||
# config.jira.site = "http://localhost:2990/jira/plugins/servlet/oauth"
|
||||
# config.jira.signature_method = "RSA-SHA1"
|
||||
# config.jira.private_key_file = "rsakey.pem"
|
||||
|
||||
# For information about Salesforce API:
|
||||
# https://developer.salesforce.com/signup &
|
||||
# https://www.salesforce.com/us/developer/docs/api_rest/
|
||||
# Salesforce callback_url must be https. You can run the following to generate self-signed ssl cert:
|
||||
# openssl req -new -newkey rsa:2048 -sha1 -days 365 -nodes -x509 -keyout server.key -out server.crt
|
||||
# Make sure you have configured the application link properly
|
||||
# config.salesforce.key = '123123'
|
||||
# config.salesforce.secret = 'acb123'
|
||||
# config.salesforce.callback_url = "https://127.0.0.1:9292/oauth/callback?provider=salesforce"
|
||||
# config.salesforce.scope = "full"
|
||||
# config.salesforce.user_info_mapping = {:email => "email"}
|
||||
|
||||
# config.line.key = ""
|
||||
# config.line.secret = ""
|
||||
# config.line.callback_url = "http://mydomain.com:3000/oauth/callback?provider=line"
|
||||
|
||||
# --- user config ---
|
||||
config.user_config do |user|
|
||||
# -- core --
|
||||
# Specify username attributes, for example: [:username, :email].
|
||||
# Default: `[:email]`
|
||||
#
|
||||
# user.username_attribute_names =
|
||||
|
||||
# Change *virtual* password attribute, the one which is used until an encrypted one is generated.
|
||||
# Default: `:password`
|
||||
#
|
||||
# user.password_attribute_name =
|
||||
|
||||
# Downcase the username before trying to authenticate, default is false
|
||||
# Default: `false`
|
||||
#
|
||||
# user.downcase_username_before_authenticating =
|
||||
|
||||
# Change default email attribute.
|
||||
# Default: `:email`
|
||||
#
|
||||
# user.email_attribute_name =
|
||||
|
||||
# Change default crypted_password attribute.
|
||||
# Default: `:crypted_password`
|
||||
#
|
||||
# user.crypted_password_attribute_name =
|
||||
|
||||
# What pattern to use to join the password with the salt
|
||||
# Default: `""`
|
||||
#
|
||||
# user.salt_join_token =
|
||||
|
||||
# Change default salt attribute.
|
||||
# Default: `:salt`
|
||||
#
|
||||
# user.salt_attribute_name =
|
||||
|
||||
# How many times to apply encryption to the password.
|
||||
# Default: 1 in test env, `nil` otherwise
|
||||
#
|
||||
user.stretches = 1 if Rails.env.test?
|
||||
|
||||
# Encryption key used to encrypt reversible encryptions such as AES256.
|
||||
# WARNING: If used for users' passwords, changing this key will leave passwords undecryptable!
|
||||
# Default: `nil`
|
||||
#
|
||||
# user.encryption_key =
|
||||
|
||||
# Use an external encryption class.
|
||||
# Default: `nil`
|
||||
#
|
||||
# user.custom_encryption_provider =
|
||||
|
||||
# Encryption algorithm name. See 'encryption_algorithm=' for available options.
|
||||
# Default: `:bcrypt`
|
||||
#
|
||||
# user.encryption_algorithm =
|
||||
|
||||
# Make this configuration inheritable for subclasses. Useful for ActiveRecord's STI.
|
||||
# Default: `false`
|
||||
#
|
||||
# user.subclasses_inherit_config =
|
||||
|
||||
# -- remember_me --
|
||||
# How long in seconds the session length will be
|
||||
# Default: `60 * 60 * 24 * 7`
|
||||
#
|
||||
# user.remember_me_for =
|
||||
|
||||
# When true, sorcery will persist a single remember me token for all
|
||||
# logins/logouts (to support remembering on multiple browsers simultaneously).
|
||||
# Default: false
|
||||
#
|
||||
# user.remember_me_token_persist_globally =
|
||||
|
||||
# -- user_activation --
|
||||
# The attribute name to hold activation state (active/pending).
|
||||
# Default: `:activation_state`
|
||||
#
|
||||
# user.activation_state_attribute_name =
|
||||
|
||||
# The attribute name to hold activation code (sent by email).
|
||||
# Default: `:activation_token`
|
||||
#
|
||||
# user.activation_token_attribute_name =
|
||||
|
||||
# The attribute name to hold activation code expiration date.
|
||||
# Default: `:activation_token_expires_at`
|
||||
#
|
||||
# user.activation_token_expires_at_attribute_name =
|
||||
|
||||
# How many seconds before the activation code expires. nil for never expires.
|
||||
# Default: `nil`
|
||||
#
|
||||
# user.activation_token_expiration_period =
|
||||
|
||||
# REQUIRED:
|
||||
# User activation mailer class.
|
||||
# Default: `nil`
|
||||
#
|
||||
# user.user_activation_mailer =
|
||||
|
||||
# When true, sorcery will not automatically
|
||||
# send the activation details email, and allow you to
|
||||
# manually handle how and when the email is sent.
|
||||
# Default: `false`
|
||||
#
|
||||
# user.activation_mailer_disabled =
|
||||
|
||||
# Method to send email related
|
||||
# options: `:deliver_later`, `:deliver_now`, `:deliver`
|
||||
# Default: :deliver (Rails version < 4.2) or :deliver_now (Rails version 4.2+)
|
||||
#
|
||||
# user.email_delivery_method =
|
||||
|
||||
# Activation needed email method on your mailer class.
|
||||
# Default: `:activation_needed_email`
|
||||
#
|
||||
# user.activation_needed_email_method_name =
|
||||
|
||||
# Activation success email method on your mailer class.
|
||||
# Default: `:activation_success_email`
|
||||
#
|
||||
# user.activation_success_email_method_name =
|
||||
|
||||
# Do you want to prevent users who did not activate by email from logging in?
|
||||
# Default: `true`
|
||||
#
|
||||
# user.prevent_non_active_users_to_login =
|
||||
|
||||
# -- reset_password --
|
||||
# Password reset token attribute name.
|
||||
# Default: `:reset_password_token`
|
||||
#
|
||||
# user.reset_password_token_attribute_name =
|
||||
|
||||
# Password token expiry attribute name.
|
||||
# Default: `:reset_password_token_expires_at`
|
||||
#
|
||||
# user.reset_password_token_expires_at_attribute_name =
|
||||
|
||||
# When was password reset email sent. Used for hammering protection.
|
||||
# Default: `:reset_password_email_sent_at`
|
||||
#
|
||||
# user.reset_password_email_sent_at_attribute_name =
|
||||
|
||||
# REQUIRED:
|
||||
# Password reset mailer class.
|
||||
# Default: `nil`
|
||||
#
|
||||
# user.reset_password_mailer =
|
||||
|
||||
# Reset password email method on your mailer class.
|
||||
# Default: `:reset_password_email`
|
||||
#
|
||||
# user.reset_password_email_method_name =
|
||||
|
||||
# When true, sorcery will not automatically
|
||||
# send the password reset details email, and allow you to
|
||||
# manually handle how and when the email is sent
|
||||
# Default: `false`
|
||||
#
|
||||
# user.reset_password_mailer_disabled =
|
||||
|
||||
# How many seconds before the reset request expires. nil for never expires.
|
||||
# Default: `nil`
|
||||
#
|
||||
# user.reset_password_expiration_period =
|
||||
|
||||
# Hammering protection: how long in seconds to wait before allowing another email to be sent.
|
||||
# Default: `5 * 60`
|
||||
#
|
||||
# user.reset_password_time_between_emails =
|
||||
|
||||
# Access counter to a reset password page attribute name
|
||||
# Default: `:access_count_to_reset_password_page`
|
||||
#
|
||||
# user.reset_password_page_access_count_attribute_name =
|
||||
|
||||
# -- magic_login --
|
||||
# Magic login code attribute name.
|
||||
# Default: `:magic_login_token`
|
||||
#
|
||||
# user.magic_login_token_attribute_name =
|
||||
|
||||
# Magic login expiry attribute name.
|
||||
# Default: `:magic_login_token_expires_at`
|
||||
#
|
||||
# user.magic_login_token_expires_at_attribute_name =
|
||||
|
||||
# When was magic login email sent — used for hammering protection.
|
||||
# Default: `:magic_login_email_sent_at`
|
||||
#
|
||||
# user.magic_login_email_sent_at_attribute_name =
|
||||
|
||||
# REQUIRED:
|
||||
# Magic login mailer class.
|
||||
# Default: `nil`
|
||||
#
|
||||
# user.magic_login_mailer_class =
|
||||
|
||||
# Magic login email method on your mailer class.
|
||||
# Default: `:magic_login_email`
|
||||
#
|
||||
# user.magic_login_email_method_name =
|
||||
|
||||
# When true, sorcery will not automatically
|
||||
# send magic login details email, and allow you to
|
||||
# manually handle how and when the email is sent
|
||||
# Default: `true`
|
||||
#
|
||||
# user.magic_login_mailer_disabled =
|
||||
|
||||
# How many seconds before the request expires. nil for never expires.
|
||||
# Default: `nil`
|
||||
#
|
||||
# user.magic_login_expiration_period =
|
||||
|
||||
# Hammering protection: how long in seconds to wait before allowing another email to be sent.
|
||||
# Default: `5 * 60`
|
||||
#
|
||||
# user.magic_login_time_between_emails =
|
||||
|
||||
# -- brute_force_protection --
|
||||
# Failed logins attribute name.
|
||||
# Default: `:failed_logins_count`
|
||||
#
|
||||
# user.failed_logins_count_attribute_name =
|
||||
|
||||
# This field indicates whether user is banned and when it will be active again.
|
||||
# Default: `:lock_expires_at`
|
||||
#
|
||||
# user.lock_expires_at_attribute_name =
|
||||
|
||||
# How many failed logins are allowed.
|
||||
# Default: `50`
|
||||
#
|
||||
# user.consecutive_login_retries_amount_limit =
|
||||
|
||||
# How long the user should be banned, in seconds. 0 for permanent.
|
||||
# Default: `60 * 60`
|
||||
#
|
||||
# user.login_lock_time_period =
|
||||
|
||||
# Unlock token attribute name
|
||||
# Default: `:unlock_token`
|
||||
#
|
||||
# user.unlock_token_attribute_name =
|
||||
|
||||
# Unlock token mailer method
|
||||
# Default: `:send_unlock_token_email`
|
||||
#
|
||||
# user.unlock_token_email_method_name =
|
||||
|
||||
# When true, sorcery will not automatically
|
||||
# send email with the unlock token
|
||||
# Default: `false`
|
||||
#
|
||||
# user.unlock_token_mailer_disabled = true
|
||||
|
||||
# REQUIRED:
|
||||
# Unlock token mailer class.
|
||||
# Default: `nil`
|
||||
#
|
||||
# user.unlock_token_mailer =
|
||||
|
||||
# -- activity logging --
|
||||
# Last login attribute name.
|
||||
# Default: `:last_login_at`
|
||||
#
|
||||
# user.last_login_at_attribute_name =
|
||||
|
||||
# Last logout attribute name.
|
||||
# Default: `:last_logout_at`
|
||||
#
|
||||
# user.last_logout_at_attribute_name =
|
||||
|
||||
# Last activity attribute name.
|
||||
# Default: `:last_activity_at`
|
||||
#
|
||||
# user.last_activity_at_attribute_name =
|
||||
|
||||
# How long since user's last activity will they be considered logged out?
|
||||
# Default: `10 * 60`
|
||||
#
|
||||
# user.activity_timeout =
|
||||
|
||||
# -- external --
|
||||
# Class which holds the various external provider data for this user.
|
||||
# Default: `nil`
|
||||
#
|
||||
user.authentications_class = Authentication
|
||||
|
||||
# User's identifier in the `authentications` class.
|
||||
# Default: `:user_id`
|
||||
#
|
||||
# user.authentications_user_id_attribute_name =
|
||||
|
||||
# Provider's identifier in the `authentications` class.
|
||||
# Default: `:provider`
|
||||
#
|
||||
# user.provider_attribute_name =
|
||||
|
||||
# User's external unique identifier in the `authentications` class.
|
||||
# Default: `:uid`
|
||||
#
|
||||
# user.provider_uid_attribute_name =
|
||||
end
|
||||
|
||||
# This line must come after the 'user config' block.
|
||||
# Define which model authenticates with sorcery.
|
||||
config.user_class = "User"
|
||||
end
|
@ -13,9 +13,18 @@ Rails.application.routes.draw do
|
||||
# short URL for form page
|
||||
get '/s/:id/form' => 'forms#form', as: :form_submitter
|
||||
|
||||
get 'oauth/callback', to: 'oauths#callback'
|
||||
get 'oauth/:provider', to: 'oauths#oauth', as: :auth_at_provider
|
||||
|
||||
get '/signup' => 'sessions#new', as: :signup # TODO: add proper signup page
|
||||
get '/login' => 'sessions#new', as: :login
|
||||
get '/logout' => 'sessions#destroy', as: :logout
|
||||
get '/auth' => 'sessions#auth', as: :auth
|
||||
|
||||
get '/demo' => 'home#demo', as: :demo
|
||||
get '/contact' => 'home#contact', as: :contact
|
||||
get '/help', to: redirect('https://www.notion.so/Tinyforms-Help-Center-04f13b5908bc46cfb4283079a3cb1149')
|
||||
|
||||
|
||||
root 'home#index'
|
||||
end
|
||||
|
@ -0,0 +1,5 @@
|
||||
class AddSequentialIdToSubmissions < ActiveRecord::Migration[6.0]
|
||||
def change
|
||||
add_column :submissions, :sequential_id, :integer
|
||||
end
|
||||
end
|
6
db/migrate/20200412214304_add_token_to_attachments.rb
Normal file
6
db/migrate/20200412214304_add_token_to_attachments.rb
Normal file
@ -0,0 +1,6 @@
|
||||
class AddTokenToAttachments < ActiveRecord::Migration[6.0]
|
||||
def change
|
||||
add_column :active_storage_attachments, :token, :string
|
||||
add_index :active_storage_attachments, :token, unique: true
|
||||
end
|
||||
end
|
15
db/migrate/20200413101920_sorcery_core.rb
Normal file
15
db/migrate/20200413101920_sorcery_core.rb
Normal file
@ -0,0 +1,15 @@
|
||||
class SorceryCore < ActiveRecord::Migration[6.0]
|
||||
def change
|
||||
add_column :users, :crypted_password, :string
|
||||
add_column :users, :salt, :string
|
||||
add_column :users, :magic_login_token, :string
|
||||
add_column :users, :magic_login_token_expires_at, :datetime
|
||||
add_column :users, :magic_login_email_sent_at, :datetime
|
||||
|
||||
add_column :authentications, :provider, :string, null: false
|
||||
add_column :authentications, :uid, :string, null: false
|
||||
|
||||
add_index :users, :magic_login_token
|
||||
add_index :users, :email, unique: true
|
||||
end
|
||||
end
|
8
db/migrate/20200413152532_add_airtable_support.rb
Normal file
8
db/migrate/20200413152532_add_airtable_support.rb
Normal file
@ -0,0 +1,8 @@
|
||||
class AddAirtableSupport < ActiveRecord::Migration[6.0]
|
||||
def change
|
||||
add_column :forms, :airtable_app_key_ciphertext, :string
|
||||
add_column :forms, :airtable_api_key_ciphertext, :string
|
||||
add_column :forms, :airtable_table, :string
|
||||
add_column :forms, :backend_name, :string
|
||||
end
|
||||
end
|
18
db/schema.rb
18
db/schema.rb
@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2020_04_09_001610) do
|
||||
ActiveRecord::Schema.define(version: 2020_04_13_152532) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
@ -21,8 +21,10 @@ ActiveRecord::Schema.define(version: 2020_04_09_001610) do
|
||||
t.bigint "record_id", null: false
|
||||
t.bigint "blob_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.string "token"
|
||||
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
|
||||
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
|
||||
t.index ["token"], name: "index_active_storage_attachments_on_token", unique: true
|
||||
end
|
||||
|
||||
create_table "active_storage_blobs", force: :cascade do |t|
|
||||
@ -43,6 +45,8 @@ ActiveRecord::Schema.define(version: 2020_04_09_001610) do
|
||||
t.datetime "updated_at", precision: 6, null: false
|
||||
t.text "access_token_ciphertext"
|
||||
t.text "refresh_token_ciphertext"
|
||||
t.string "provider"
|
||||
t.string "uid"
|
||||
end
|
||||
|
||||
create_table "forms", force: :cascade do |t|
|
||||
@ -53,6 +57,10 @@ ActiveRecord::Schema.define(version: 2020_04_09_001610) do
|
||||
t.string "thank_you_url"
|
||||
t.datetime "created_at", precision: 6, null: false
|
||||
t.datetime "updated_at", precision: 6, null: false
|
||||
t.string "airtable_app_key_ciphertext"
|
||||
t.string "airtable_api_key_ciphertext"
|
||||
t.string "airtable_table"
|
||||
t.string "backend_name"
|
||||
end
|
||||
|
||||
create_table "submissions", force: :cascade do |t|
|
||||
@ -63,6 +71,7 @@ ActiveRecord::Schema.define(version: 2020_04_09_001610) do
|
||||
t.datetime "updated_at", precision: 6, null: false
|
||||
t.string "remote_ip"
|
||||
t.string "referrer"
|
||||
t.integer "sequential_id"
|
||||
end
|
||||
|
||||
create_table "users", force: :cascade do |t|
|
||||
@ -71,6 +80,13 @@ ActiveRecord::Schema.define(version: 2020_04_09_001610) do
|
||||
t.string "google_id"
|
||||
t.datetime "created_at", precision: 6, null: false
|
||||
t.datetime "updated_at", precision: 6, null: false
|
||||
t.string "crypted_password"
|
||||
t.string "salt"
|
||||
t.string "magic_login_token"
|
||||
t.datetime "magic_login_token_expires_at"
|
||||
t.datetime "magic_login_email_sent_at"
|
||||
t.index ["email"], name: "index_users_on_email", unique: true
|
||||
t.index ["magic_login_token"], name: "index_users_on_magic_login_token"
|
||||
end
|
||||
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
|
36
lib/spreadsheet_backends/airtable.rb
Normal file
36
lib/spreadsheet_backends/airtable.rb
Normal file
@ -0,0 +1,36 @@
|
||||
require 'airrecord'
|
||||
module SpreadsheetBackends
|
||||
class Airtable
|
||||
|
||||
attr_accessor :form, :user
|
||||
|
||||
def initialize(form)
|
||||
@form = form
|
||||
@user = form.user
|
||||
end
|
||||
|
||||
def url
|
||||
"https://airtable.com/#{form.airtable_app_key}"
|
||||
end
|
||||
|
||||
def append(data)
|
||||
result = table.create(data, typecast: true)
|
||||
result.id.present?
|
||||
rescue Airrecord::Error => e
|
||||
return false
|
||||
end
|
||||
|
||||
def create
|
||||
# Airtable must already exist
|
||||
# TODO: maybe add validation here?
|
||||
end
|
||||
|
||||
def headers
|
||||
table.records.first&.fields&.keys # we only know the headers once we have at least one record
|
||||
end
|
||||
|
||||
def table
|
||||
@table ||= Airrecord.table(form.airtable_api_key, form.airtable_app_key, form.airtable_table)
|
||||
end
|
||||
end
|
||||
end
|
71
lib/spreadsheet_backends/google_sheets.rb
Normal file
71
lib/spreadsheet_backends/google_sheets.rb
Normal file
@ -0,0 +1,71 @@
|
||||
require 'google/apis/sheets_v4'
|
||||
module SpreadsheetBackends
|
||||
class GoogleSheets
|
||||
# Hash to translate an index to a A1 notation. e.g. 1 => 'B', 27 => 'AA'
|
||||
COLUMN_INDEX_TO_LETTER = Hash.new {|hash,key| hash[key] = hash[key - 1].next }.merge({0 => "A"})
|
||||
|
||||
attr_accessor :form, :user
|
||||
|
||||
def initialize(form)
|
||||
@form = form
|
||||
@user = form.user
|
||||
end
|
||||
|
||||
def url
|
||||
"https://docs.google.com/spreadsheets/d/#{form.google_spreadsheet_id}/edit" if form.google_spreadsheet_id.present?
|
||||
end
|
||||
|
||||
def append(data)
|
||||
data = data.with_indifferent_access
|
||||
check_spreadsheed_headers!(data)
|
||||
|
||||
values = headers.map { |key| data[key] }
|
||||
range = "A1:A#{COLUMN_INDEX_TO_LETTER[values.length]}1"
|
||||
value_range = Google::Apis::SheetsV4::ValueRange.new(values: [values], major_dimension: 'ROWS')
|
||||
|
||||
result = spreadsheet_service.append_spreadsheet_value(form.google_spreadsheet_id, range, value_range, value_input_option: 'USER_ENTERED')
|
||||
result.updates.updated_rows > 0
|
||||
end
|
||||
|
||||
def create
|
||||
sheets = Google::Apis::SheetsV4::SheetsService.new
|
||||
sheets.authorization = user.google_authorization
|
||||
create_object = Google::Apis::SheetsV4::Spreadsheet.new(properties: { title: form.title})
|
||||
spreadsheet = sheets.create_spreadsheet(create_object)
|
||||
form.update(google_spreadsheet_id: spreadsheet.spreadsheet_id)
|
||||
end
|
||||
|
||||
def headers
|
||||
@headers ||= begin
|
||||
values = spreadsheet_service.get_spreadsheet_values(form.google_spreadsheet_id, 'A1:An').values
|
||||
# if there are no headers yet, return an empty array
|
||||
if values
|
||||
values[0].map(&:strip)
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def spreadsheet_service
|
||||
@spreadsheet_service ||= Google::Apis::SheetsV4::SheetsService.new.tap do |s|
|
||||
s.authorization = user.google_authorization
|
||||
end
|
||||
end
|
||||
|
||||
def check_spreadsheed_headers!(data)
|
||||
missing_headers = data.keys.map { |k| k.to_s.strip } - headers
|
||||
append_missing_headers(missing_headers) unless missing_headers.empty?
|
||||
end
|
||||
|
||||
def append_missing_headers(missing_headers)
|
||||
start_column = COLUMN_INDEX_TO_LETTER[headers.length]
|
||||
end_column = COLUMN_INDEX_TO_LETTER[headers.length + missing_headers.length]
|
||||
range = "#{start_column}1:#{end_column}1"
|
||||
value_range = Google::Apis::SheetsV4::ValueRange.new(values: [missing_headers], major_dimension: 'ROWS')
|
||||
spreadsheet_service.update_spreadsheet_value(form.google_spreadsheet_id, range, value_range, value_input_option: 'USER_ENTERED')
|
||||
@headers = nil # reset header values to refresh memoization on next access
|
||||
end
|
||||
|
||||
end
|
||||
end
|
Loading…
x
Reference in New Issue
Block a user