forms dashboard now entirely on react.
- setup js tooling (node modules and Makefile) - setup prettier so js code can be standardized - move js and scss source dirs around - setup react and react-router, making them take control of /forms, /dashboard and /forms/<hashid> and so on - remove all forms dashboard templates, moving their markup and logic to jsx - remove unnecessary js code and rename submissions.scss to settings.scss - create formspree/forms/api.py and move all actions created from the dashboard into there - remove some bloat
This commit is contained in:
parent
7ce164e945
commit
0eb778d803
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"presets": [
|
||||
"@babel/preset-env",
|
||||
"@babel/preset-react"
|
||||
]
|
||||
}
|
|
@ -5,6 +5,10 @@ npm-debug.log
|
|||
ssl-keys
|
||||
dart-sass
|
||||
dump.rdb
|
||||
formspree/static/bundle.js
|
||||
formspree/static/bundle.min.js
|
||||
formspree/static/main.css
|
||||
*tmp-browserify*
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
all: formspree/static/bundle.js formspree/static/main.css
|
||||
|
||||
watch:
|
||||
find formspree/js/ formspree/scss/ -name '*.js' -o -name '*.scss' | entr make
|
||||
|
||||
$(shell find formspree/js/):
|
||||
./node_modules/.bin/prettier "formspree/js/**/*.js"
|
||||
|
||||
formspree/static/bundle.js: $(shell find formspree/js)
|
||||
./node_modules/.bin/browserify formspree/js/main.js -dv --outfile formspree/static/bundle.js
|
||||
|
||||
formspree/static/bundle.min.js: $(shell find formspree/js)
|
||||
./node_modules/.bin/browserify formspree/js/main.js -g [ envify --NODE_ENV production ] -g uglifyify | ./node_modules/.bin/uglifyjs --compress --mangle > formspree/static/bundle.min.js
|
||||
|
||||
formspree/static/main.css: $(shell find formspree/scss) dart-sass/src/dart
|
||||
cd dart-sass && ./sass ../formspree/scss/main.scss ../formspree/static/main.css
|
||||
|
||||
dart-sass/src/dart:
|
||||
echo -e "\n\ninstall dart-sass from https://github.com/sass/dart-sass/releases\n\n"
|
|
@ -0,0 +1,203 @@
|
|||
from flask import request, jsonify, g
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from formspree import settings
|
||||
from formspree.stuff import DB
|
||||
from formspree.utils import jsonerror, IS_VALID_EMAIL
|
||||
from .helpers import referrer_to_path, sitewide_file_check, remove_www, \
|
||||
referrer_to_baseurl
|
||||
from .models import Form, Submission
|
||||
|
||||
|
||||
@login_required
|
||||
def list():
|
||||
# grab all the forms this user controls
|
||||
if current_user.upgraded:
|
||||
forms = current_user.forms.order_by(Form.id.desc()).all()
|
||||
else:
|
||||
forms = []
|
||||
|
||||
return jsonify({
|
||||
'ok': True,
|
||||
'user': {
|
||||
'upgraded': current_user.upgraded,
|
||||
'email': current_user.email
|
||||
},
|
||||
'forms': [f.serialize() for f in forms]
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def create():
|
||||
# check that this request came from user dashboard to prevent XSS and CSRF
|
||||
referrer = referrer_to_baseurl(request.referrer)
|
||||
service = referrer_to_baseurl(settings.SERVICE_URL)
|
||||
if referrer != service:
|
||||
return jsonerror(400, {'error': 'Improper request.'})
|
||||
|
||||
if not current_user.upgraded:
|
||||
g.log.info('Failed to create form from dashboard. User is not upgraded.')
|
||||
return jsonerror(402, {'error': "Please upgrade your account."})
|
||||
|
||||
email = request.get_json().get('email')
|
||||
url = request.get_json().get('url')
|
||||
sitewide = request.get_json().get('sitewide')
|
||||
|
||||
g.log = g.log.bind(email=email, url=url, sitewide=sitewide)
|
||||
|
||||
if not IS_VALID_EMAIL(email):
|
||||
g.log.info('Failed to create form from dashboard. Invalid address.')
|
||||
return jsonerror(400, {'error': "The provided email address is not valid."})
|
||||
|
||||
g.log.info('Creating a new form from the dashboard.')
|
||||
|
||||
email = email.lower().strip() # case-insensitive
|
||||
form = Form(email, owner=current_user)
|
||||
if url:
|
||||
url = 'http://' + url if not url.startswith('http') else url
|
||||
form.host = referrer_to_path(url)
|
||||
|
||||
# sitewide forms, verified with a file at the root of the target domain
|
||||
if sitewide:
|
||||
if sitewide_file_check(url, email):
|
||||
form.host = remove_www(referrer_to_path(urljoin(url, '/'))[:-1])
|
||||
form.sitewide = True
|
||||
else:
|
||||
return jsonerror(403, {
|
||||
'error': u"Couldn't verify the file at {}.".format(url)
|
||||
})
|
||||
|
||||
DB.session.add(form)
|
||||
DB.session.commit()
|
||||
|
||||
if form.host:
|
||||
# when the email and url are provided, we can automatically confirm the form
|
||||
# but only if the email is registered for this account
|
||||
for email in current_user.emails:
|
||||
if email.address == form.email:
|
||||
g.log.info('No need for email confirmation.')
|
||||
form.confirmed = True
|
||||
DB.session.add(form)
|
||||
DB.session.commit()
|
||||
break
|
||||
else:
|
||||
# in case the email isn't registered for this user
|
||||
# we automatically send the email confirmation
|
||||
form.send_confirmation()
|
||||
|
||||
return jsonify({
|
||||
'ok': True,
|
||||
'hashid': form.hashid,
|
||||
'submission_url': settings.API_ROOT + '/' + form.hashid,
|
||||
'confirmed': form.confirmed
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def get(hashid):
|
||||
if not current_user.upgraded:
|
||||
return jsonerror(402, {'error': "Please upgrade your account."})
|
||||
|
||||
form = Form.get_with_hashid(hashid)
|
||||
if not form:
|
||||
return jsonerror(404, {'error': "Form not found."})
|
||||
|
||||
for cont in form.controllers:
|
||||
if cont.id == current_user.id: break
|
||||
else:
|
||||
return jsonerror(401, {'error': "You do not control this form."})
|
||||
|
||||
submissions, fields = form.submissions_with_fields()
|
||||
|
||||
ret = form.serialize()
|
||||
ret['submissions'] = submissions
|
||||
ret['fields'] = fields
|
||||
|
||||
return jsonify(ret)
|
||||
|
||||
|
||||
@login_required
|
||||
def update(hashid):
|
||||
# check that this request came from user dashboard to prevent XSS and CSRF
|
||||
referrer = referrer_to_baseurl(request.referrer)
|
||||
service = referrer_to_baseurl(settings.SERVICE_URL)
|
||||
if referrer != service:
|
||||
return jsonerror(400, {'error': 'Improper request.'})
|
||||
|
||||
form = Form.get_with_hashid(hashid)
|
||||
if not form:
|
||||
return jsonerror(400, {'error': 'Not a valid form.'})
|
||||
|
||||
if form.owner_id != current_user.id and form not in current_user.forms:
|
||||
return jsonerror(401, {'error': 'Wrong user.'})
|
||||
|
||||
patch = request.get_json()
|
||||
|
||||
for attr in ['disable_storage', 'disabled', 'disable_email', 'captcha_disabled']:
|
||||
if attr in patch:
|
||||
setattr(form, attr, patch[attr])
|
||||
|
||||
DB.session.add(form)
|
||||
DB.session.commit()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
@login_required
|
||||
def delete(hashid):
|
||||
# check that this request came from user dashboard to prevent XSS and CSRF
|
||||
referrer = referrer_to_baseurl(request.referrer)
|
||||
service = referrer_to_baseurl(settings.SERVICE_URL)
|
||||
|
||||
if referrer != service:
|
||||
return jsonerror(400, {'error': 'Improper request.'})
|
||||
|
||||
form = Form.get_with_hashid(hashid)
|
||||
if not form:
|
||||
return jsonerror(400, {'error': 'Not a valid form.'})
|
||||
|
||||
if form.owner_id != current_user.id and form not in current_user.forms:
|
||||
return jsonerror(401, {'error': 'Wrong user.'})
|
||||
|
||||
for submission in form.submissions:
|
||||
DB.session.delete(submission)
|
||||
DB.session.delete(form)
|
||||
DB.session.commit()
|
||||
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
@login_required
|
||||
def submission_delete(hashid, submissionid):
|
||||
# check that this request came from user dashboard to prevent XSS and CSRF
|
||||
referrer = referrer_to_baseurl(request.referrer)
|
||||
service = referrer_to_baseurl(settings.SERVICE_URL)
|
||||
if referrer != service:
|
||||
return jsonerror(400, {'error': 'Improper request.'})
|
||||
|
||||
form = Form.get_with_hashid(hashid)
|
||||
if not form:
|
||||
return jsonerror(400, {'error': 'Not a valid form.'})
|
||||
|
||||
if form.owner_id != current_user.id and form not in current_user.forms:
|
||||
return jsonerror(401, {'error': 'Wrong user.'})
|
||||
|
||||
submission = Submission.query.get(submissionid)
|
||||
if not submission:
|
||||
return jsonerror(401, 'Not a valid submission.')
|
||||
|
||||
DB.session.delete(submission)
|
||||
form.counter -= 1
|
||||
DB.session.add(form)
|
||||
DB.session.commit()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
@login_required
|
||||
def sitewide_check():
|
||||
email = request.get_json().get('email')
|
||||
url = request.get_json().get('url')
|
||||
|
||||
if sitewide_file_check(url, email):
|
||||
return jsonify({'ok': True})
|
||||
else:
|
||||
return jsonify({'ok': False})
|
|
@ -91,6 +91,7 @@ def sitewide_file_check(url, email):
|
|||
|
||||
g.log = g.log.bind(url=url, email=email)
|
||||
|
||||
try:
|
||||
res = requests.get(url, timeout=3, headers={
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/55.0.2883.87 Chrome/55.0.2883.87 Safari/537.36'
|
||||
})
|
||||
|
@ -103,8 +104,9 @@ def sitewide_file_check(url, email):
|
|||
if line == email:
|
||||
g.log.debug('Email found in sitewide file.')
|
||||
return True
|
||||
except requests.exceptions.ConnectionError:
|
||||
pass
|
||||
|
||||
g.log.warn('Email not found in sitewide file.', contents=res.text[:100])
|
||||
return False
|
||||
|
||||
|
||||
|
@ -119,14 +121,6 @@ def verify_captcha(form_data, request):
|
|||
return r.ok and r.json().get('success')
|
||||
|
||||
|
||||
def valid_domain_request(request):
|
||||
# check that this request came from user dashboard to prevent XSS and CSRF
|
||||
referrer = referrer_to_baseurl(request.referrer)
|
||||
service = referrer_to_baseurl(settings.SERVICE_URL)
|
||||
|
||||
return referrer == service
|
||||
|
||||
|
||||
def assign_ajax(form, sent_using_ajax):
|
||||
if form.uses_ajax is None:
|
||||
form.uses_ajax = sent_using_ajax
|
||||
|
@ -168,16 +162,3 @@ def fetch_first_submission(nonce):
|
|||
return json.loads(jsondata.decode('utf-8'))
|
||||
except:
|
||||
return None
|
||||
|
||||
def check_valid_form_settings_request(form):
|
||||
if not valid_domain_request(request):
|
||||
return jsonify(error='The request you made is not valid.<br />Please visit your dashboard and try again.'), 400
|
||||
|
||||
if form.owner_id != current_user.id and form not in current_user.forms:
|
||||
return jsonify(
|
||||
error='You aren\'t the owner of that form.<br />Please log in as the form owner and try again.'), 400
|
||||
|
||||
if not form:
|
||||
return jsonify(error='That form does not exist. Please check the link and try again.'), 400
|
||||
|
||||
return True
|
||||
|
|
|
@ -116,6 +116,47 @@ class Form(DB.Model):
|
|||
except IndexError:
|
||||
return None
|
||||
|
||||
def serialize(self):
|
||||
return {
|
||||
'sitewide': self.sitewide,
|
||||
'hashid': self.hashid,
|
||||
'hash': self.hash,
|
||||
'counter': self.counter,
|
||||
'email': self.email,
|
||||
'host': self.host,
|
||||
'confirm_sent': self.confirm_sent,
|
||||
'confirmed': self.confirmed,
|
||||
'disabled': self.disabled,
|
||||
'captcha_disabled': self.captcha_disabled,
|
||||
'disable_email': self.disable_email,
|
||||
'disable_storage': self.disable_storage,
|
||||
'is_public': bool(self.hash),
|
||||
'url': '{S}/{E}'.format(
|
||||
S=settings.SERVICE_URL,
|
||||
E=self.hashid
|
||||
)
|
||||
}
|
||||
|
||||
def submissions_with_fields(self):
|
||||
'''
|
||||
Fetch all submissions, extract all fields names from every submission
|
||||
into a single fields list, excluding the KEYS_NOT_STORED values, because
|
||||
they are worthless.
|
||||
Add the special 'date' field to every submission entry, based on
|
||||
.submitted_at, and use this as the first field on the fields array.
|
||||
'''
|
||||
|
||||
fields = set()
|
||||
submissions = []
|
||||
for s in self.submissions:
|
||||
fields.update(s.data.keys())
|
||||
s.data['date'] = s.submitted_at.isoformat()
|
||||
s.data['id'] = s.id
|
||||
submissions.append(s.data)
|
||||
|
||||
fields = ['date'] + sorted(fields - KEYS_NOT_STORED)
|
||||
return submissions, fields
|
||||
|
||||
def send(self, data, keys, referrer):
|
||||
'''
|
||||
Sends form to user's email.
|
||||
|
|
|
@ -20,9 +20,7 @@ from formspree.utils import request_wants_json, jsonerror, IS_VALID_EMAIL, \
|
|||
from .helpers import http_form_to_dict, ordered_storage, referrer_to_path, \
|
||||
remove_www, referrer_to_baseurl, sitewide_file_check, \
|
||||
verify_captcha, temp_store_hostname, get_temp_hostname, \
|
||||
HASH, assign_ajax, valid_domain_request, \
|
||||
KEYS_NOT_STORED, KEYS_EXCLUDED_FROM_EMAIL, \
|
||||
check_valid_form_settings_request
|
||||
HASH, assign_ajax, KEYS_EXCLUDED_FROM_EMAIL
|
||||
from .models import Form, Submission
|
||||
|
||||
|
||||
|
@ -486,181 +484,30 @@ def confirm_email(nonce):
|
|||
|
||||
|
||||
@login_required
|
||||
def forms():
|
||||
'''
|
||||
A reminder: this is the /forms endpoint, but for GET requests
|
||||
it is also the /dashboard endpoint.
|
||||
|
||||
The /dashboard endpoint, the address gave by url_for('dashboard'),
|
||||
is the target of a lot of redirects around the app, but it can
|
||||
be changed later to point to somewhere else.
|
||||
'''
|
||||
|
||||
# grab all the forms this user controls
|
||||
if current_user.upgraded:
|
||||
forms = current_user.forms.order_by(Form.id.desc()).all()
|
||||
else:
|
||||
forms = []
|
||||
|
||||
if request_wants_json():
|
||||
return jsonify({
|
||||
'ok': True,
|
||||
'forms': [{
|
||||
'email': f.email,
|
||||
'host': f.host,
|
||||
'confirm_sent': f.confirm_sent,
|
||||
'confirmed': f.confirmed,
|
||||
'is_public': bool(f.hash),
|
||||
'url': '{S}/{E}'.format(
|
||||
S=settings.SERVICE_URL,
|
||||
E=f.hashid
|
||||
)
|
||||
} for f in forms]
|
||||
})
|
||||
else:
|
||||
return render_template('forms/list.html',
|
||||
enabled_forms=[form for form in forms if not form.disabled],
|
||||
disabled_forms=[form for form in forms if form.disabled]
|
||||
)
|
||||
def serve_dashboard(hashid=None, s=None):
|
||||
return render_template('forms/dashboard.html')
|
||||
|
||||
|
||||
@login_required
|
||||
def create_form():
|
||||
# create a new form
|
||||
|
||||
if not current_user.upgraded:
|
||||
g.log.info('Failed to create form from dashboard. User is not upgraded.')
|
||||
return jsonerror(402, {'error': "Please upgrade your account."})
|
||||
|
||||
if request.get_json():
|
||||
email = request.get_json().get('email')
|
||||
url = request.get_json().get('url')
|
||||
sitewide = request.get_json().get('sitewide')
|
||||
else:
|
||||
email = request.form.get('email')
|
||||
url = request.form.get('url')
|
||||
sitewide = request.form.get('sitewide')
|
||||
|
||||
g.log = g.log.bind(email=email, url=url, sitewide=sitewide)
|
||||
|
||||
if not IS_VALID_EMAIL(email):
|
||||
g.log.info('Failed to create form from dashboard. Invalid address.')
|
||||
if request_wants_json():
|
||||
return jsonerror(400, {'error': "The provided email address is not valid."})
|
||||
else:
|
||||
flash(u'The provided email address is not valid.', 'error')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
g.log.info('Creating a new form from the dashboard.')
|
||||
|
||||
email = email.lower() # case-insensitive
|
||||
form = Form(email, owner=current_user)
|
||||
if url:
|
||||
url = 'http://' + url if not url.startswith('http') else url
|
||||
form.host = referrer_to_path(url)
|
||||
|
||||
# sitewide forms, verified with a file at the root of the target domain
|
||||
if sitewide:
|
||||
if sitewide_file_check(url, email):
|
||||
form.host = remove_www(referrer_to_path(urljoin(url, '/'))[:-1])
|
||||
form.sitewide = True
|
||||
else:
|
||||
return jsonerror(403, {
|
||||
'error': u"Couldn't verify the file at {}.".format(url)
|
||||
})
|
||||
|
||||
DB.session.add(form)
|
||||
DB.session.commit()
|
||||
|
||||
if form.host:
|
||||
# when the email and url are provided, we can automatically confirm the form
|
||||
# but only if the email is registered for this account
|
||||
for email in current_user.emails:
|
||||
if email.address == form.email:
|
||||
g.log.info('No need for email confirmation.')
|
||||
form.confirmed = True
|
||||
DB.session.add(form)
|
||||
DB.session.commit()
|
||||
break
|
||||
else:
|
||||
# in case the email isn't registered for this user
|
||||
# we automatically send the email confirmation
|
||||
form.send_confirmation()
|
||||
|
||||
if request_wants_json():
|
||||
return jsonify({
|
||||
'ok': True,
|
||||
'hashid': form.hashid,
|
||||
'submission_url': settings.API_ROOT + '/' + form.hashid,
|
||||
'confirmed': form.confirmed
|
||||
})
|
||||
else:
|
||||
flash(u'Your new form endpoint was created!', 'success')
|
||||
return redirect(url_for('dashboard', new=form.hashid) + '#form-' + form.hashid)
|
||||
|
||||
|
||||
@login_required
|
||||
def sitewide_check():
|
||||
email = request.args.get('email')
|
||||
url = request.args.get('url')
|
||||
|
||||
if sitewide_file_check(url, email):
|
||||
return '', 200
|
||||
else:
|
||||
return '', 404
|
||||
|
||||
|
||||
@login_required
|
||||
def form_submissions(hashid, format=None):
|
||||
def export_submissions(hashid, format=None):
|
||||
if not current_user.upgraded:
|
||||
return jsonerror(402, {'error': "Please upgrade your account."})
|
||||
|
||||
form = Form.get_with_hashid(hashid)
|
||||
|
||||
for cont in form.controllers:
|
||||
if cont.id == current_user.id: break
|
||||
else:
|
||||
if request_wants_json():
|
||||
return jsonerror(403, {'error': "You do not control this form."})
|
||||
else:
|
||||
return redirect(url_for('dashboard'))
|
||||
return abort(401)
|
||||
|
||||
if not format:
|
||||
# normal request.
|
||||
if request_wants_json():
|
||||
return jsonify({
|
||||
'host': form.host,
|
||||
'email': form.email,
|
||||
'submissions': [dict(s.data, date=s.submitted_at.isoformat()) for s in form.submissions]
|
||||
})
|
||||
else:
|
||||
fields = set()
|
||||
for s in form.submissions:
|
||||
fields.update(s.data.keys())
|
||||
fields -= KEYS_NOT_STORED
|
||||
submissions, fields = form.submissions_with_fields()
|
||||
|
||||
submissions = []
|
||||
for sub in form.submissions:
|
||||
for f in fields:
|
||||
value = sub.data.get(f, '')
|
||||
typ = type(value)
|
||||
sub.data[f] = value if typ is str \
|
||||
else pyaml.dump(value, safe=True)
|
||||
submissions.append(sub)
|
||||
|
||||
return render_template('forms/submissions.html',
|
||||
form=form,
|
||||
fields=sorted(fields),
|
||||
submissions=submissions
|
||||
)
|
||||
elif format:
|
||||
# an export request, format can be json or csv
|
||||
if format == 'json':
|
||||
return Response(
|
||||
json.dumps({
|
||||
'host': form.host,
|
||||
'email': form.email,
|
||||
'submissions': [dict(s.data, date=s.submitted_at.isoformat()) for s in form.submissions]
|
||||
'fields': fields,
|
||||
'submissions': submissions
|
||||
}, sort_keys=True, indent=2),
|
||||
mimetype='application/json',
|
||||
headers={
|
||||
|
@ -670,13 +517,11 @@ def form_submissions(hashid, format=None):
|
|||
)
|
||||
elif format == 'csv':
|
||||
out = io.BytesIO()
|
||||
fieldnames = set(field for sub in form.submissions for field in sub.data.keys())
|
||||
fieldnames = ['date'] + sorted(fieldnames)
|
||||
|
||||
w = csv.DictWriter(out, fieldnames=fieldnames, encoding='utf-8')
|
||||
w = csv.DictWriter(out, fieldnames=fields, encoding='utf-8')
|
||||
w.writeheader()
|
||||
for sub in form.submissions:
|
||||
w.writerow(dict(sub.data, date=sub.submitted_at.isoformat()))
|
||||
for sub in submissions:
|
||||
w.writerow(sub)
|
||||
|
||||
return Response(
|
||||
out.getvalue(),
|
||||
|
@ -686,149 +531,3 @@ def form_submissions(hashid, format=None):
|
|||
% (hashid, datetime.datetime.now().isoformat().split('.')[0])
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def form_recaptcha_toggle(hashid):
|
||||
form = Form.get_with_hashid(hashid)
|
||||
valid_check = check_valid_form_settings_request(form)
|
||||
if valid_check != True:
|
||||
return valid_check
|
||||
|
||||
checked_status = request.json['checked']
|
||||
form.captcha_disabled = not checked_status
|
||||
DB.session.add(form)
|
||||
DB.session.commit()
|
||||
|
||||
if form.captcha_disabled:
|
||||
return jsonify(disabled=True, message='CAPTCHA successfully disabled')
|
||||
else:
|
||||
return jsonify(disabled=False, message='CAPTCHA successfully enabled')
|
||||
|
||||
@login_required
|
||||
def form_email_notification_toggle(hashid):
|
||||
form = Form.get_with_hashid(hashid)
|
||||
valid_check = check_valid_form_settings_request(form)
|
||||
if valid_check != True:
|
||||
return valid_check
|
||||
|
||||
checked_status = request.json['checked']
|
||||
form.disable_email = not checked_status
|
||||
DB.session.add(form)
|
||||
DB.session.commit()
|
||||
|
||||
if form.disable_email:
|
||||
return jsonify(disabled=True, message='Email notifications successfully disabled')
|
||||
else:
|
||||
return jsonify(disabled=False, message='Email notifications successfully enabled')
|
||||
|
||||
@login_required
|
||||
def form_archive_toggle(hashid):
|
||||
form = Form.get_with_hashid(hashid)
|
||||
valid_check = check_valid_form_settings_request(form)
|
||||
if valid_check != True:
|
||||
return valid_check
|
||||
|
||||
checked_status = request.json['checked']
|
||||
form.disable_storage = not checked_status
|
||||
DB.session.add(form)
|
||||
DB.session.commit()
|
||||
|
||||
if form.disable_storage:
|
||||
return jsonify(disabled=True, message='Submission archive successfully disabled')
|
||||
else:
|
||||
return jsonify(disabled=False, message='Submission archive successfully enabled')
|
||||
|
||||
@login_required
|
||||
def form_toggle(hashid):
|
||||
form = Form.get_with_hashid(hashid)
|
||||
|
||||
# check that this request came from user dashboard to prevent XSS and CSRF
|
||||
if not valid_domain_request(request):
|
||||
return render_template('error.html',
|
||||
title='Improper Request',
|
||||
text='The request you made is not valid.<br />Please visit your dashboard and try again.'), 400
|
||||
|
||||
if form.owner_id != current_user.id:
|
||||
if form not in current_user.forms: #accounts for bug when form isn't assigned owner_id bc it was not created from dashboard
|
||||
return render_template('error.html',
|
||||
title='Wrong user',
|
||||
text='You aren\'t the owner of that form.<br />Please log in as the form owner and try again.'), 400
|
||||
if not form:
|
||||
return render_template('error.html',
|
||||
title='Not a valid form',
|
||||
text='That form does not exist.<br />Please check the link and try again.'), 400
|
||||
else:
|
||||
form.disabled = not form.disabled
|
||||
DB.session.add(form)
|
||||
DB.session.commit()
|
||||
if form.disabled:
|
||||
flash(u'Form successfully disabled', 'success')
|
||||
else:
|
||||
flash(u'Form successfully enabled', 'success')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
|
||||
@login_required
|
||||
def form_deletion(hashid):
|
||||
form = Form.get_with_hashid(hashid)
|
||||
|
||||
# check that this request came from user dashboard to prevent XSS and CSRF
|
||||
referrer = referrer_to_baseurl(request.referrer)
|
||||
service = referrer_to_baseurl(settings.SERVICE_URL)
|
||||
if referrer != service:
|
||||
return render_template('error.html',
|
||||
title='Improper Request',
|
||||
text='The request you made is not valid.<br />Please visit your dashboard and try again.'), 400
|
||||
|
||||
if form.owner_id != current_user.id:
|
||||
if form not in current_user.forms: #accounts for bug when form isn't assigned owner_id bc it was not created from dashboard
|
||||
return render_template('error.html',
|
||||
title='Wrong user',
|
||||
text='You aren\'t the owner of that form.<br />Please log in as the form owner and try again.'), 400
|
||||
if not form:
|
||||
return render_template('error.html',
|
||||
title='Not a valid form',
|
||||
text='That form does not exist.<br />Please check the link and try again.'), 400
|
||||
else:
|
||||
for submission in form.submissions:
|
||||
DB.session.delete(submission)
|
||||
DB.session.delete(form)
|
||||
DB.session.commit()
|
||||
flash(u'Form successfully deleted', 'success')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
|
||||
@login_required
|
||||
def submission_deletion(hashid, submissionid):
|
||||
submission = Submission.query.get(submissionid)
|
||||
form = Form.get_with_hashid(hashid)
|
||||
|
||||
# check that this request came from user dashboard to prevent XSS and CSRF
|
||||
referrer = referrer_to_baseurl(request.referrer)
|
||||
service = referrer_to_baseurl(settings.SERVICE_URL)
|
||||
if referrer != service:
|
||||
return render_template('error.html',
|
||||
title='Improper Request',
|
||||
text='The request you made is not valid.<br />Please visit your dashboard and try again.'), 400
|
||||
|
||||
if form.owner_id != current_user.id:
|
||||
if form not in current_user.forms: #accounts for bug when form isn't assigned owner_id bc it was not created from dashboard
|
||||
return render_template('error.html',
|
||||
title='Wrong user',
|
||||
text='You aren\'t the owner of that form.<br />Please log in as the form owner and try again.' + str(form.id)), 400
|
||||
if not submission:
|
||||
return render_template('error.html',
|
||||
title='Not a valid submission',
|
||||
text='That submission does not exist.<br />Please check the link and try again.'), 400
|
||||
elif submission.form_id != form.id:
|
||||
return render_template('error.html',
|
||||
title='Not a valid submissions',
|
||||
text='That submission does not match the form provided.<br />Please check the link and try again.'), 400
|
||||
else:
|
||||
DB.session.delete(submission)
|
||||
form.counter -= 1
|
||||
DB.session.add(form)
|
||||
DB.session.commit()
|
||||
flash(u'Submission successfully deleted', 'success')
|
||||
return redirect(url_for('form-submissions', hashid=hashid))
|
||||
|
|
|
@ -0,0 +1,262 @@
|
|||
/** @format */
|
||||
|
||||
const url = require('url')
|
||||
const isValidUrl = require('valid-url').isWebUri
|
||||
const isValidEmail = require('is-valid-email')
|
||||
const React = require('react')
|
||||
const toastr = window.toastr
|
||||
|
||||
const modals = require('../modals')
|
||||
|
||||
module.exports = class CreateForm extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
this.setEmail = this.setEmail.bind(this)
|
||||
this.setURL = this.setURL.bind(this)
|
||||
this.setSitewide = this.setSitewide.bind(this)
|
||||
this.validate = this.validate.bind(this)
|
||||
this.create = this.create.bind(this)
|
||||
this.checkSitewide = this.checkSitewide.bind(this)
|
||||
|
||||
this.state = {
|
||||
url: '',
|
||||
email: '',
|
||||
sitewide: false,
|
||||
|
||||
invalid: null,
|
||||
verified: false,
|
||||
disableVerification: false
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
modals()
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.user.upgraded) {
|
||||
return (
|
||||
<h6 className="light">
|
||||
Please <a href="/account">upgrade your account</a> in order to create
|
||||
forms from the dashboard and manage the forms currently associated
|
||||
with your emails.
|
||||
</h6>
|
||||
)
|
||||
}
|
||||
|
||||
let {
|
||||
email,
|
||||
url: urlv,
|
||||
sitewide,
|
||||
invalid,
|
||||
verified,
|
||||
disableVerification
|
||||
} = this.state
|
||||
|
||||
return (
|
||||
<div className="col-1-1">
|
||||
<div className="create-form">
|
||||
<a href="#create-form" className="button">
|
||||
Create a form
|
||||
</a>
|
||||
|
||||
<div className="modal" id="create-form" aria-hidden="true">
|
||||
<div className="container">
|
||||
<div className="x">
|
||||
<h4>Create form</h4>
|
||||
<a href="#">×</a>
|
||||
</div>
|
||||
<form onSubmit={this.create}>
|
||||
<div className="col-1-1">
|
||||
<h4>Send email to:</h4>
|
||||
<input
|
||||
type="email"
|
||||
onChange={this.setEmail}
|
||||
value={email}
|
||||
placeholder="You can point this form to any email address"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-1-1">
|
||||
<h4>From URL:</h4>
|
||||
<input
|
||||
type="url"
|
||||
onChange={this.setURL}
|
||||
value={urlv}
|
||||
placeholder="Leave blank to send confirmation email when first submitted"
|
||||
/>
|
||||
</div>
|
||||
<div className="container">
|
||||
<div className="col-1-4">
|
||||
<label
|
||||
className="hint--bottom"
|
||||
data-hint="A site-wide form is a form that you can place on all pages of your website -- and you just have to confirm once!"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sitewide}
|
||||
onChange={this.setSitewide}
|
||||
value="true"
|
||||
/>
|
||||
site-wide
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-3-4 info">
|
||||
{invalid ? (
|
||||
<div className="red">
|
||||
{invalid === 'email' ? (
|
||||
'Please input a valid email address.'
|
||||
) : (
|
||||
<>
|
||||
Please input a valid URL, for example:
|
||||
<span className="code">
|
||||
{url.resolve(
|
||||
'http://www.mywebsite.com',
|
||||
sitewide ? '' : '/contact.html'
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : !sitewide || (sitewide && verified) ? (
|
||||
<div>​</div>
|
||||
) : (
|
||||
<span>
|
||||
Please ensure
|
||||
<span className="code">
|
||||
{url.resolve(urlv, '/formspree-verify.txt')}
|
||||
</span>
|
||||
exists and contains a line with
|
||||
<span className="code">{email}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-1-3">
|
||||
<div className="verify">
|
||||
<button
|
||||
style={sitewide ? {} : {visibility: 'hidden'}}
|
||||
disabled={!sitewide && !invalid && !disableVerification}
|
||||
onClick={this.checkSitewide}
|
||||
>
|
||||
Verify
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-1-3">​</div>
|
||||
<div className="col-1-3">
|
||||
<div className="create">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
!((sitewide && verified) || (!sitewide && !invalid))
|
||||
}
|
||||
>
|
||||
Create form
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
setEmail(e) {
|
||||
this.setState({email: e.target.value}, this.validate)
|
||||
}
|
||||
|
||||
setURL(e) {
|
||||
this.setState({url: e.target.value}, this.validate)
|
||||
}
|
||||
|
||||
setSitewide(e) {
|
||||
this.setState({sitewide: e.target.checked}, this.validate)
|
||||
}
|
||||
|
||||
validate() {
|
||||
this.setState(st => {
|
||||
st.invalid = null
|
||||
|
||||
let {email, url: urlv, sitewide} = st
|
||||
urlv = /^https?:\/\//.test(urlv) ? urlv : 'http://' + urlv
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
st.invalid = 'email'
|
||||
return
|
||||
}
|
||||
|
||||
if (sitewide) {
|
||||
if (urlv && !isValidUrl(urlv)) {
|
||||
st.invalid = 'urlv'
|
||||
}
|
||||
} else {
|
||||
if (urlv && urlv !== 'http://' && !isValidUrl(urlv)) {
|
||||
st.invalid = 'urlv'
|
||||
}
|
||||
}
|
||||
|
||||
return st
|
||||
})
|
||||
}
|
||||
|
||||
async checkSitewide(e) {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
let r = await (await fetch(`/api/forms/sitewide-check`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({email: this.state.email, url: this.state.url}),
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})).json()
|
||||
|
||||
if (!r.ok) {
|
||||
toastr.warning("The verification file wasn't found.")
|
||||
this.setState({verified: false, disableVerification: true})
|
||||
|
||||
setTimeout(() => {
|
||||
this.setState({disableVerification: false})
|
||||
}, 5000)
|
||||
return
|
||||
}
|
||||
|
||||
toastr.success('The file exists! you can create your site-wide form now.')
|
||||
this.setState({verified: true})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toastr.error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async create(e) {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
let r = await (await fetch('/api/forms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: this.state.email,
|
||||
url: this.state.url,
|
||||
sitewide: this.state.sitewide
|
||||
}),
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})).json()
|
||||
|
||||
toastr.success('Form created!')
|
||||
this.props.history.push(`/forms/${r.hashid}`)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toastr.error(e.message)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,200 @@
|
|||
/** @format */
|
||||
|
||||
const React = require('react')
|
||||
const cs = require('class-set')
|
||||
const {Link} = require('react-router-dom')
|
||||
const createPortal = require('react-dom').createPortal
|
||||
|
||||
const CreateForm = require('./CreateForm')
|
||||
const HeaderPortal = require('./HeaderPortal')
|
||||
|
||||
module.exports = class FormList extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
loading: true,
|
||||
user: {},
|
||||
enabled_forms: [],
|
||||
disabled_forms: [],
|
||||
error: null
|
||||
}
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
try {
|
||||
let r = await (await fetch('/api/forms', {
|
||||
credentials: 'same-origin',
|
||||
headers: {Accept: 'application/json'}
|
||||
})).json()
|
||||
|
||||
this.setState({
|
||||
user: r.user,
|
||||
enabled_forms: r.forms.filter(f => !f.disabled),
|
||||
disabled_forms: r.forms.filter(f => f.disabled),
|
||||
loading: false
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
this.setState({error: e.message})
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.loading) {
|
||||
return (
|
||||
<div className="col-1-1">
|
||||
<p>loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div className="col-1-1">
|
||||
<p>
|
||||
An error has ocurred while we were trying to fetch your forms,
|
||||
please try again or contact us at support@formspree.io.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderPortal>
|
||||
<h1>Your Forms</h1>
|
||||
</HeaderPortal>
|
||||
<div className="col-1-1">
|
||||
<h4>Active Forms</h4>
|
||||
{this.state.enabled_forms.length ? (
|
||||
<table className="forms responsive">
|
||||
<tbody>
|
||||
{this.state.enabled_forms.map(form => (
|
||||
<FormItem key={form.hashid} {...form} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p>
|
||||
No active forms found. Forms can be enabled by clicking the unlock
|
||||
icon below.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{this.state.disabled_forms.length ? (
|
||||
<>
|
||||
<h4>Disabled Forms</h4>
|
||||
<table className="forms responsive">
|
||||
<tbody>
|
||||
{this.state.disabled_forms.map(form => (
|
||||
<FormItem key={form.hashid} {...form} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{this.state.enabled_forms.length === 0 &&
|
||||
this.state.disabled_forms.length === 0 &&
|
||||
this.user.upgraded ? (
|
||||
<h6 className="light">
|
||||
You don't have any forms associated with this account, maybe you
|
||||
should <a href="/account">verify your email</a>.
|
||||
</h6>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<CreateForm user={this.state.user} {...this.props} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class FormItem extends React.Component {
|
||||
render() {
|
||||
let form = this.props
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={cs({
|
||||
new: form.counter === 0,
|
||||
verified: form.confirmed,
|
||||
waiting_confirmation: form.confirm_sent
|
||||
})}
|
||||
>
|
||||
<td data-label="Status">
|
||||
<Link to={`/forms/${form.hashid}/settings`} className="no-underline">
|
||||
{form.host ? (
|
||||
form.confirmed ? (
|
||||
<span
|
||||
className="tooltip hint--right"
|
||||
data-hint="Form confirmed"
|
||||
>
|
||||
<span className="ion-checkmark-round" />
|
||||
</span>
|
||||
) : form.confirm_sent ? (
|
||||
<span
|
||||
className="tooltip hint--right"
|
||||
data-hint="Waiting confirmation"
|
||||
>
|
||||
<span className="ion-pause" />
|
||||
</span>
|
||||
) : null
|
||||
) : (
|
||||
<span
|
||||
className="tooltip hint--right"
|
||||
data-hint="New form. Place the code generated here in some page and submit it!"
|
||||
>
|
||||
<span className="ion-help" />
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</td>
|
||||
<td data-label="URL">
|
||||
<Link to={`/forms/${form.hashid}/settings`} className="no-underline">
|
||||
{form.host ? (
|
||||
<div>
|
||||
{form.host}
|
||||
{form.sitewide ? (
|
||||
<span
|
||||
className="highlight tooltip hint--top"
|
||||
data-hint="This form works for all paths under {{ form.host }}/"
|
||||
>
|
||||
/ *
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
'Waiting for a submission'
|
||||
)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="target-email" data-label="Target email address">
|
||||
<Link
|
||||
to={`/forms/${form.hashid}/settings`}
|
||||
className="no-underline"
|
||||
>
|
||||
<span
|
||||
className="hint--top"
|
||||
data-hint={`https://${location.host}/${
|
||||
form.hash ? form.email : form.hashid
|
||||
}`}
|
||||
>
|
||||
{form.email}
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="n-submissions" data-label="Submissions counter">
|
||||
<Link to={`/forms/${form.hashid}/submissions`} className="no-underline">
|
||||
{form.counter == 0 ? (
|
||||
<span className="never">never submitted</span>
|
||||
) : (
|
||||
`${form.counter} submissions`
|
||||
)}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,476 @@
|
|||
/** @format */
|
||||
|
||||
const toastr = window.toastr
|
||||
const React = require('react')
|
||||
const {Route, NavLink, Redirect} = require('react-router-dom')
|
||||
const CodeMirror = require('react-codemirror2')
|
||||
require('codemirror/mode/xml/xml')
|
||||
|
||||
const HeaderPortal = require('./HeaderPortal')
|
||||
|
||||
module.exports = class FormPage extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
form: null
|
||||
}
|
||||
|
||||
this.fetchForm = this.fetchForm.bind(this)
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
this.fetchForm()
|
||||
}
|
||||
|
||||
render() {
|
||||
let hashid = this.props.match.params.hashid
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderPortal>
|
||||
<h1>{hashid}</h1>
|
||||
<h3>
|
||||
<NavLink
|
||||
to={`/forms/${hashid}/submissions`}
|
||||
activeStyle={{color: 'inherit', cursor: 'normal'}}
|
||||
>
|
||||
Submission History
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to={`/forms/${hashid}/settings`}
|
||||
activeStyle={{color: 'inherit', cursor: 'normal'}}
|
||||
>
|
||||
Form Settings
|
||||
</NavLink>
|
||||
</h3>
|
||||
</HeaderPortal>
|
||||
<Route
|
||||
exact
|
||||
path={`/forms/${hashid}`}
|
||||
render={() => <Redirect to={`/forms/${hashid}/submissions`} />}
|
||||
/>
|
||||
{this.state.form && (
|
||||
<>
|
||||
<Route
|
||||
path="/forms/:hashid/submissions"
|
||||
render={() => (
|
||||
<FormSubmissions
|
||||
form={this.state.form}
|
||||
onUpdate={this.fetchForm}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="/forms/:hashid/settings"
|
||||
render={() => (
|
||||
<FormSettings
|
||||
form={this.state.form}
|
||||
history={this.props.history}
|
||||
onUpdate={this.fetchForm}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
async fetchForm() {
|
||||
let hashid = this.props.match.params.hashid
|
||||
|
||||
try {
|
||||
let r = await (await fetch(`/api/forms/${hashid}`, {
|
||||
credentials: 'same-origin',
|
||||
headers: {Accept: 'application/json'}
|
||||
})).json()
|
||||
|
||||
this.setState({form: r})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toastr.error(`Failed to fetch form, see the console for more details.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FormSubmissions extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
this.deleteSubmission = this.deleteSubmission.bind(this)
|
||||
}
|
||||
|
||||
render() {
|
||||
let {form} = this.props
|
||||
|
||||
return (
|
||||
<div className="col-1-1 submissions-col">
|
||||
<h2>
|
||||
Submissions for
|
||||
{!form.hash ? (
|
||||
<span className="code">/{form.hashid}</span>
|
||||
) : (
|
||||
<span className="code">/{form.email}</span>
|
||||
)}
|
||||
on <span className="code">{form.host}</span>
|
||||
{form.sitewide ? 'and all its subpaths.' : null}
|
||||
{form.hash ? (
|
||||
<>
|
||||
<br />
|
||||
<small>
|
||||
you can now replace the email in the URL with{' '}
|
||||
<span className="code">{`/${form.hashid}`}</span>
|
||||
</small>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<br />
|
||||
<small>
|
||||
targeting <span className="code">{form.email}</span>
|
||||
</small>
|
||||
</>
|
||||
)}
|
||||
</h2>
|
||||
{form.submissions.length ? (
|
||||
<table className="submissions responsive">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Submitted at</th>
|
||||
{form.fields.slice(1 /* the first field is 'date' */).map(f => (
|
||||
<th key={f}>{f}</th>
|
||||
))}
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{form.submissions.map(s => (
|
||||
<tr id={`submission-${s.id}`} key={s.id}>
|
||||
<td id={`p-${s.id}`} data-label="Submitted at">
|
||||
{new Date(Date.parse(s.date))
|
||||
.toString()
|
||||
.split(' ')
|
||||
.slice(0, 5)
|
||||
.join(' ')}
|
||||
</td>
|
||||
{form.fields
|
||||
.slice(1 /* the first field is 'date' */)
|
||||
.map(f => (
|
||||
<td data-label={f} key={f}>
|
||||
<pre>{s[f]}</pre>
|
||||
</td>
|
||||
))}
|
||||
<td>
|
||||
<button
|
||||
className="no-border"
|
||||
data-sub={s.id}
|
||||
onClick={this.deleteSubmission}
|
||||
>
|
||||
<i className="fa fa-trash-o delete" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<h3>No submissions archived yet.</h3>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async deleteSubmission(e) {
|
||||
e.preventDefault()
|
||||
|
||||
let subid = e.currentTarget.dataset.sub
|
||||
|
||||
try {
|
||||
let r = await (await fetch(
|
||||
`/api/forms/${this.props.form.hashid}/submissions/${subid}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: {Accept: 'application/json'}
|
||||
}
|
||||
)).json()
|
||||
|
||||
if (r.error) {
|
||||
toastr.warning(`Failed to delete submission: ${r.error}`)
|
||||
return
|
||||
}
|
||||
|
||||
toastr.success('Submission deleted.')
|
||||
this.props.onUpdate()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toastr.error(
|
||||
'Failed to delete submission, see the console for more details.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FormSettings extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
this.update = this.update.bind(this)
|
||||
this.deleteForm = this.deleteForm.bind(this)
|
||||
this.cancelDelete = this.cancelDelete.bind(this)
|
||||
|
||||
this.state = {
|
||||
deleting: false
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let {form} = this.props
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="col-1-1" id="settings">
|
||||
<h2>Sample HTML</h2>
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-1-1">
|
||||
<p>
|
||||
Use this code in your HTML, modifying it according to your
|
||||
needs:
|
||||
</p>
|
||||
<CodeMirror.UnControlled
|
||||
value={`<form
|
||||
action="${form.url}"
|
||||
method="POST"
|
||||
>
|
||||
<label>
|
||||
Your email:
|
||||
<input type="text" name="_replyto">
|
||||
</label>
|
||||
<label>
|
||||
Your message:
|
||||
<textarea name="message"></textarea>
|
||||
</label>
|
||||
|
||||
<!-- your other form fields go here -->
|
||||
|
||||
<button type="submit">Send</button>
|
||||
</form>`}
|
||||
options={{
|
||||
theme: 'oceanic-next',
|
||||
mode: 'xml',
|
||||
viewportMargin: Infinity
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-1-1" id="settings">
|
||||
<h2>Form Settings</h2>
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-5-6">
|
||||
<h4>Form Enabled</h4>
|
||||
<p className="description">
|
||||
You can disable this form to cause it to stop receiving new
|
||||
submissions temporarily or permanently.
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-1-6 switch-row">
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={this.update}
|
||||
checked={!form.disabled}
|
||||
name="disabled"
|
||||
/>
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-5-6">
|
||||
<div>
|
||||
<h4>reCAPTCHA</h4>
|
||||
</div>
|
||||
<p className="description">
|
||||
reCAPTCHA provides vital spam protection, but you can turn it
|
||||
off if you need.
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-1-6 switch-row">
|
||||
<div>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={this.update}
|
||||
checked={!form.captcha_disabled}
|
||||
name="captcha_disabled"
|
||||
/>
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-5-6">
|
||||
<h4>Email Notifications</h4>
|
||||
<p className="description">
|
||||
You can disable the emails Formspree sends if you just want to
|
||||
download the submissions from the dashboard.
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-1-6 switch-row">
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={this.update}
|
||||
checked={!form.disable_email}
|
||||
name="disable_email"
|
||||
/>
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-5-6">
|
||||
<h4>Submission Archive</h4>
|
||||
<p className="description">
|
||||
You can disable the submission archive if you don't want
|
||||
Formspree to store your submissions.
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-1-6 switch-row">
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={this.update}
|
||||
checked={!form.disable_storage}
|
||||
name="disable_storage"
|
||||
/>
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className={this.state.deleting ? 'col-1-2' : 'col-5-6'}>
|
||||
<h4>
|
||||
{this.state.deleting
|
||||
? 'Are you sure you want to delete?'
|
||||
: 'Delete Form'}
|
||||
</h4>
|
||||
<p className="description">
|
||||
{this.state.deleting ? (
|
||||
<span>
|
||||
This will delete the form on <b>{form.host}</b> targeting{' '}
|
||||
<b>{form.email}</b> and all its submissions? This action{' '}
|
||||
<b>cannot</b> be undone.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Deleting the form will erase all traces of this form on
|
||||
our databases, including all the submissions.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
(this.state.deleting ? 'col-1-2' : 'col-1-6') + ' switch-row'
|
||||
}
|
||||
>
|
||||
{this.state.deleting ? (
|
||||
<>
|
||||
<button
|
||||
onClick={this.deleteForm}
|
||||
className="no-uppercase destructive"
|
||||
>
|
||||
Sure, erase everything
|
||||
</button>
|
||||
<button
|
||||
onClick={this.cancelDelete}
|
||||
className="no-uppercase"
|
||||
style={{marginRight: '5px'}}
|
||||
>
|
||||
No, don't delete!
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<a onClick={this.deleteForm} href="#">
|
||||
<i className="fa fa-trash-o delete" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
async update(e) {
|
||||
try {
|
||||
let res = await (await fetch(`/api/forms/${this.props.form.hashid}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
[e.currentTarget.name]: !e.currentTarget.checked
|
||||
}),
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})).json()
|
||||
|
||||
if (res.error) {
|
||||
toastr.warning(`Failed to save settings: ${res.error}`)
|
||||
return
|
||||
}
|
||||
|
||||
toastr.success('Settings saved.')
|
||||
this.props.onUpdate()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toastr.error('Failed to update form. See the console for more details.')
|
||||
}
|
||||
}
|
||||
|
||||
cancelDelete(e) {
|
||||
e.preventDefault()
|
||||
this.setState({deleting: false})
|
||||
}
|
||||
|
||||
async deleteForm(e) {
|
||||
e.preventDefault()
|
||||
|
||||
if (this.props.form.counter > 0 && !this.state.deleting) {
|
||||
// double-check the user intentions to delete,
|
||||
// but only if the form has been used already.
|
||||
this.setState({deleting: true})
|
||||
return
|
||||
}
|
||||
|
||||
this.setState({deleting: false})
|
||||
try {
|
||||
let res = await (await fetch(`/api/forms/${this.props.form.hashid}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
}
|
||||
})).json()
|
||||
|
||||
if (res.error) {
|
||||
toastr.warning(`Failed to delete form: ${res.error}`)
|
||||
return
|
||||
}
|
||||
|
||||
toastr.success('Form successfully deleted.')
|
||||
this.props.history.push('/forms')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toastr.error('Failed to delete form. See the console for more details.')
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
/** @format */
|
||||
|
||||
const createPortal = require('react-dom').createPortal
|
||||
|
||||
module.exports = function TitlePortal(props) {
|
||||
return createPortal(props.children, document.querySelector('#header .center'))
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
/** @format */
|
||||
|
||||
const React = require('react')
|
||||
const render = require('react-dom').render
|
||||
const {BrowserRouter: Router, Route} = require('react-router-dom')
|
||||
|
||||
const FormList = require('./FormList')
|
||||
const FormPage = require('./FormPage')
|
||||
|
||||
class Dashboard extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<Router>
|
||||
<>
|
||||
<Route exact path="/forms" component={FormList} />
|
||||
<Route exact path="/dashboard" component={FormList} />
|
||||
<Route path="/forms/:hashid" component={FormPage} />
|
||||
</>
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
render(<Dashboard />, document.querySelector('.container.block'))
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* @format
|
||||
*/
|
||||
|
||||
const $ = window.$
|
||||
const StripeCheckout = window.StripeCheckout
|
||||
const toastr = window.toastr
|
||||
|
||||
toastr.options = {positionClass: 'toast-top-center'}
|
||||
|
||||
/* top navbar */
|
||||
var nav = $('body > nav')
|
||||
nav.addClass('js')
|
||||
nav.find('.menu').slicknav()
|
||||
nav
|
||||
.find('h4')
|
||||
.clone()
|
||||
.prependTo('.slicknav_menu')
|
||||
|
||||
/* adding a shadow at the bottom of the menu bar only when not at the top */
|
||||
var w = $(window)
|
||||
w.scroll(function() {
|
||||
var scrollPos = w.scrollTop()
|
||||
if (scrollPos && !nav.hasClass('scrolled')) {
|
||||
nav.addClass('scrolled')
|
||||
} else if (!scrollPos) {
|
||||
nav.removeClass('scrolled')
|
||||
}
|
||||
})
|
||||
|
||||
/* background-color should inherit, but CSS's "inherit" breaks z-index */
|
||||
var bgcolor = $(document.body).css('background-color')
|
||||
if (bgcolor.split(',').length === 4 || bgcolor === 'transparent') {
|
||||
bgcolor = 'white'
|
||||
}
|
||||
nav.css('background-color', bgcolor)
|
||||
|
||||
/* modals -- working with or without JS */
|
||||
require('./modals')()
|
||||
|
||||
/* turning flask flash messages into js popup notifications */
|
||||
window.popupMessages.forEach(function(m, i) {
|
||||
var category = m[0] || 'info'
|
||||
var text = m[1]
|
||||
setTimeout(function() {
|
||||
toastr[category](text)
|
||||
}, (1 + i) * 1500)
|
||||
})
|
||||
|
||||
/* stripe checkout */
|
||||
var stripebutton = $('#stripe-upgrade')
|
||||
if (stripebutton.length) {
|
||||
var handler = StripeCheckout.configure(stripebutton.data())
|
||||
stripebutton.on('click', function(e) {
|
||||
handler.open({
|
||||
token: function(token) {
|
||||
stripebutton
|
||||
.closest('form')
|
||||
.append(
|
||||
`<input type="hidden" name="stripeToken" value="${token.id}">`
|
||||
)
|
||||
.append(
|
||||
`<input type="hidden" name="stripeEmail" value="${token.email}">`
|
||||
)
|
||||
.submit()
|
||||
}
|
||||
})
|
||||
e.preventDefault()
|
||||
})
|
||||
}
|
||||
|
||||
/* quick script for showing the resend confirmation form */
|
||||
$('a.resend').on('click', function() {
|
||||
$(this).hide()
|
||||
$('form.resend').show()
|
||||
return false
|
||||
})
|
||||
|
||||
/* scripts at other files */
|
||||
require('./forms/dashboard.js')
|
||||
|
||||
/* toggle the card management menu */
|
||||
$(function() {
|
||||
$('#card-list tr:even').addClass('even')
|
||||
$('#card-list tr:not(.even)').hide()
|
||||
$('#card-list tr:first-child').show()
|
||||
|
||||
$('#card-list tr.even').click(function() {
|
||||
$(this)
|
||||
.next('tr')
|
||||
.toggle()
|
||||
$(this)
|
||||
.find('.arrow')
|
||||
.toggleClass('up')
|
||||
$(this)
|
||||
.find('.fa-chevron-right')
|
||||
.toggleClass('fa-rotate-90')
|
||||
})
|
||||
})
|
|
@ -0,0 +1,53 @@
|
|||
/** @format */
|
||||
|
||||
const $ = window.$
|
||||
|
||||
module.exports = function modals() {
|
||||
$('.modal').each(function() {
|
||||
let modal = $(this)
|
||||
modal.addClass('js')
|
||||
let id = modal.attr('id')
|
||||
|
||||
$(`[href="#${id}"]`).click(function(e) {
|
||||
// open the modal
|
||||
e.preventDefault()
|
||||
modal.toggleClass('target')
|
||||
})
|
||||
|
||||
modal.click(function(e) {
|
||||
// close the modal
|
||||
if (e.target === modal[0]) {
|
||||
cleanHash()
|
||||
modal.toggleClass('target')
|
||||
e.preventDefault()
|
||||
}
|
||||
})
|
||||
modal.find('.x a').click(function(e) {
|
||||
// close the modal
|
||||
cleanHash()
|
||||
e.preventDefault()
|
||||
modal.toggleClass('target')
|
||||
})
|
||||
})
|
||||
|
||||
function cleanHash() {
|
||||
if (!window.location.hash) return
|
||||
if (window.history && window.history.replaceState) {
|
||||
window.history.replaceState('', document.title, window.location.pathname)
|
||||
} else {
|
||||
let pos = $(window).scrollTop()
|
||||
window.location.hash = ''
|
||||
$(window).scrollTop(pos)
|
||||
}
|
||||
}
|
||||
|
||||
// activate modals from url hash #
|
||||
setTimeout(() => {
|
||||
// setTimeout is needed because :target elements only appear after
|
||||
// the page is loaded or something like that.
|
||||
let activatedModal = $('*:target')
|
||||
if (activatedModal.length && !activatedModal.is('.target')) {
|
||||
activatedModal.toggleClass('target')
|
||||
}
|
||||
}, 0)
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
/** @format */
|
||||
|
||||
const url = require('url')
|
||||
const isValidUrl = require('valid-url').isWebUri
|
||||
const isValidEmail = require('is-valid-email')
|
||||
|
@ -11,7 +13,7 @@ const $ = window.$
|
|||
const toastr = window.toastr
|
||||
|
||||
/* create-form validation for site-wide forms */
|
||||
module.exports = function sitewide () {
|
||||
module.exports = function sitewide() {
|
||||
var parentNode = $('#create-form .container')
|
||||
if (!parentNode.length) return
|
||||
|
||||
|
@ -37,20 +39,29 @@ module.exports = function sitewide () {
|
|||
parentNode.on('input', 'input[name="url"], input[name="email"]', run)
|
||||
parentNode.on('click', '.verify button', check)
|
||||
|
||||
function run () {
|
||||
function run() {
|
||||
let checkbox = parentNode.find('input[name="sitewide"]')
|
||||
|
||||
let email = parentNode.find('input[name="email"]').val().trim()
|
||||
let urlv = parentNode.find('input[name="url"]').val().trim()
|
||||
let email = parentNode
|
||||
.find('input[name="email"]')
|
||||
.val()
|
||||
.trim()
|
||||
let urlv = parentNode
|
||||
.find('input[name="url"]')
|
||||
.val()
|
||||
.trim()
|
||||
urlv = /^https?:\/\//.test(urlv) ? urlv : 'http://' + urlv
|
||||
let sitewide = checkbox.is(':checked')
|
||||
|
||||
// wrong input
|
||||
if (!isValidEmail(email)) { // invalid email
|
||||
if (!isValidEmail(email)) {
|
||||
// invalid email
|
||||
data.invalid = 'email'
|
||||
} else if (sitewide && !isValidUrl(urlv)) { // invalid url with sitewide
|
||||
} else if (sitewide && !isValidUrl(urlv)) {
|
||||
// invalid url with sitewide
|
||||
data.invalid = 'url'
|
||||
} else if (!sitewide && urlv && urlv !== 'http://' && !isValidUrl(urlv)) { // invalid url without sitewide
|
||||
} else if (!sitewide && urlv && urlv !== 'http://' && !isValidUrl(urlv)) {
|
||||
// invalid url without sitewide
|
||||
data.invalid = 'url'
|
||||
} else {
|
||||
data.invalid = null
|
||||
|
@ -63,15 +74,17 @@ module.exports = function sitewide () {
|
|||
apply(render(data))
|
||||
}
|
||||
|
||||
function check () {
|
||||
function check() {
|
||||
$.ajax({
|
||||
url: '/forms/sitewide-check?' + parentNode.find('form').serialize(),
|
||||
success: function () {
|
||||
toastr.success('The file exists! you can create your site-wide form now.')
|
||||
success: function() {
|
||||
toastr.success(
|
||||
'The file exists! you can create your site-wide form now.'
|
||||
)
|
||||
data.verified = true
|
||||
apply(render(data))
|
||||
},
|
||||
error: function () {
|
||||
error: function() {
|
||||
toastr.warning("The verification file wasn't found.")
|
||||
data.verified = false
|
||||
data.disableVerification = true
|
||||
|
@ -87,17 +100,29 @@ module.exports = function sitewide () {
|
|||
return false
|
||||
}
|
||||
|
||||
function apply (vtree) {
|
||||
function apply(vtree) {
|
||||
let patches = diff(tree, vtree)
|
||||
rootNode = patch(rootNode, patches)
|
||||
tree = vtree
|
||||
}
|
||||
|
||||
function render ({invalid, sitewide, verified, urlv, email, disableVerification}) {
|
||||
function render({
|
||||
invalid,
|
||||
sitewide,
|
||||
verified,
|
||||
urlv,
|
||||
email,
|
||||
disableVerification
|
||||
}) {
|
||||
return h('form', {method: 'post', action: formActionURL}, [
|
||||
h('.col-1-1', [
|
||||
h('h4', 'Send email to:'),
|
||||
h('input', {type: 'email', name: 'email', placeholder: emailPlaceholder, value: email})
|
||||
h('input', {
|
||||
type: 'email',
|
||||
name: 'email',
|
||||
placeholder: emailPlaceholder,
|
||||
value: email
|
||||
})
|
||||
]),
|
||||
h('.col-1-1', [
|
||||
h('h4', 'From URL:'),
|
||||
|
@ -112,13 +137,22 @@ module.exports = function sitewide () {
|
|||
]),
|
||||
h('.col-3-4.info', [
|
||||
invalid
|
||||
? h('div.red', invalid === 'email'
|
||||
? h(
|
||||
'div.red',
|
||||
invalid === 'email'
|
||||
? 'Please input a valid email address.'
|
||||
: [
|
||||
'Please input a valid URL. For example: ',
|
||||
h('span.code', url.resolve('http://www.mywebsite.com', sitewide ? '' : '/contact.html'))
|
||||
])
|
||||
: sitewide && verified || !sitewide
|
||||
h(
|
||||
'span.code',
|
||||
url.resolve(
|
||||
'http://www.mywebsite.com',
|
||||
sitewide ? '' : '/contact.html'
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
: (sitewide && verified) || !sitewide
|
||||
? h('div', {innerHTML: '​'})
|
||||
: h('span', [
|
||||
'Please ensure ',
|
||||
|
@ -129,18 +163,21 @@ module.exports = function sitewide () {
|
|||
]),
|
||||
h('.col-1-3', [
|
||||
h('.verify', [
|
||||
h('button', sitewide && !invalid && !disableVerification
|
||||
h(
|
||||
'button',
|
||||
sitewide && !invalid && !disableVerification
|
||||
? {}
|
||||
: sitewide
|
||||
? {disabled: true}
|
||||
: {style: {visibility: 'hidden'}, disabled: true},
|
||||
'Verify')
|
||||
'Verify'
|
||||
)
|
||||
])
|
||||
]),
|
||||
h('.col-1-3', {innerHTML: '​'}),
|
||||
h('.col-1-3', [
|
||||
h('.create', [
|
||||
sitewide && verified || !sitewide && !invalid
|
||||
(sitewide && verified) || (!sitewide && !invalid)
|
||||
? h('button', {type: 'submit'}, 'Create form')
|
||||
: h('button', {disabled: true}, 'Create form')
|
||||
])
|
|
@ -1,4 +1,5 @@
|
|||
import formspree.forms.views as fv
|
||||
import formspree.forms.api as fa
|
||||
import formspree.users.views as uv
|
||||
import formspree.static_pages.views as sv
|
||||
|
||||
|
@ -37,18 +38,18 @@ def configure_routes(app):
|
|||
app.add_url_rule('/logout', 'logout', view_func=uv.logout, methods=['GET'])
|
||||
|
||||
# Users' forms
|
||||
app.add_url_rule('/dashboard', 'dashboard', view_func=fv.forms, methods=['GET'])
|
||||
app.add_url_rule('/forms', 'forms', view_func=fv.forms, methods=['GET'])
|
||||
app.add_url_rule('/forms', 'create-form', view_func=fv.create_form, methods=['POST'])
|
||||
app.add_url_rule('/forms/sitewide-check', view_func=fv.sitewide_check, methods=['GET'])
|
||||
app.add_url_rule('/forms/<hashid>/', 'form-submissions', view_func=fv.form_submissions, methods=['GET'])
|
||||
app.add_url_rule('/forms/<hashid>.<format>', 'form-submissions', view_func=fv.form_submissions, methods=['GET'])
|
||||
app.add_url_rule('/forms/<hashid>/toggle-recaptcha', 'toggle-recaptcha', view_func=fv.form_recaptcha_toggle, methods=['POST'])
|
||||
app.add_url_rule('/forms/<hashid>/toggle-emails', 'toggle-emails', view_func=fv.form_email_notification_toggle, methods=['POST'])
|
||||
app.add_url_rule('/forms/<hashid>/toggle-storage', 'toggle-storage', view_func=fv.form_archive_toggle, methods=['POST'])
|
||||
app.add_url_rule('/forms/<hashid>/toggle', 'form-toggle', view_func=fv.form_toggle, methods=['POST'])
|
||||
app.add_url_rule('/forms/<hashid>/delete', 'form-deletion', view_func=fv.form_deletion, methods=['POST'])
|
||||
app.add_url_rule('/forms/<hashid>/delete/<submissionid>', 'submission-deletion', view_func=fv.submission_deletion, methods=['POST'])
|
||||
app.add_url_rule('/dashboard', 'dashboard', view_func=fv.serve_dashboard, methods=['GET'])
|
||||
app.add_url_rule('/forms', 'dashboard', view_func=fv.serve_dashboard, methods=['GET'])
|
||||
app.add_url_rule('/forms/<hashid>', view_func=fv.serve_dashboard, methods=['GET'])
|
||||
app.add_url_rule('/forms/<hashid>/<path:s>', view_func=fv.serve_dashboard, methods=['GET'])
|
||||
app.add_url_rule('/forms/<hashid>.<format>', view_func=fv.export_submissions, methods=['GET'])
|
||||
app.add_url_rule('/api/forms', view_func=fa.list, methods=['GET'])
|
||||
app.add_url_rule('/api/forms', view_func=fa.create, methods=['POST'])
|
||||
app.add_url_rule('/api/forms/<hashid>', view_func=fa.get, methods=['GET'])
|
||||
app.add_url_rule('/api/forms/<hashid>', view_func=fa.update, methods=['PATCH'])
|
||||
app.add_url_rule('/api/forms/<hashid>', view_func=fa.delete, methods=['DELETE'])
|
||||
app.add_url_rule('/api/forms/sitewide-check', view_func=fa.sitewide_check, methods=['POST'])
|
||||
app.add_url_rule('/api/forms/<hashid>/submissions/<submissionid>', view_func=fa.submission_delete, methods=['DELETE'])
|
||||
|
||||
# Webhooks
|
||||
app.add_url_rule('/webhooks/stripe', view_func=uv.stripe_webhook, methods=['POST'])
|
||||
|
|
|
@ -111,6 +111,14 @@
|
|||
}
|
||||
|
||||
.dashboard {
|
||||
#header {
|
||||
h3 {
|
||||
a {
|
||||
margin: 0 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.row:after {
|
||||
content: "";
|
||||
display: none;
|
||||
|
@ -444,38 +452,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
.html-highlight {
|
||||
clear: both;
|
||||
font-size: 14px;
|
||||
font-family: monospace;
|
||||
padding: 5px;
|
||||
border: 2px dashed #696969;
|
||||
border-radius: 5px;
|
||||
|
||||
.bracket {
|
||||
color: #a65700
|
||||
}
|
||||
.tagname {
|
||||
color: #800000;
|
||||
font-weight: bold;
|
||||
}
|
||||
.attrkey {
|
||||
color: #074726
|
||||
}
|
||||
.equal {
|
||||
color: #808030
|
||||
}
|
||||
.attrvalue {
|
||||
color: #0000e6
|
||||
}
|
||||
.comment {
|
||||
color: #696969
|
||||
}
|
||||
}
|
||||
|
||||
a.button.export {
|
||||
width: 11em;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
margin: 0 0 20px 20px;
|
||||
}
|
||||
|
||||
.CodeMirror {
|
||||
border: 1px solid #eee;
|
||||
padding: 10px;
|
||||
height: auto;
|
||||
font-size: 90%;
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
// Ionicons Variables
|
||||
// --------------------------
|
||||
|
||||
$ionicons-font-path: "../fonts" !default;
|
||||
$ionicons-font-path: "fonts" !default;
|
||||
$ionicons-font-family: "Ionicons" !default;
|
||||
$ionicons-version: "2.0.1" !default;
|
||||
$ionicons-prefix: ion- !default;
|
|
@ -22,7 +22,7 @@ $error-red: #CC3F36;
|
|||
@import 'dashboard.scss';
|
||||
@import 'nav.scss';
|
||||
@import 'toastr.scss';
|
||||
@import 'submissions.scss';
|
||||
@import 'settings.scss';
|
||||
@import 'ionicons/ionicons.scss';
|
||||
|
||||
html {
|
|
@ -0,0 +1,89 @@
|
|||
/** @format */
|
||||
|
||||
#settings {
|
||||
p#status {
|
||||
float: right;
|
||||
color: $dark-blue;
|
||||
font-size: 0.7em;
|
||||
margin-right: 2%;
|
||||
|
||||
&.error {
|
||||
color: $red;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 14px 0 4px 0 !important;
|
||||
}
|
||||
|
||||
p.description {
|
||||
line-height: 1em;
|
||||
display: inline-block;
|
||||
font-size: 0.8em;
|
||||
margin: 0;
|
||||
margin-top: 0.2em;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
margin: auto;
|
||||
position: relative;
|
||||
text-align: right;
|
||||
.switch {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
height: 26px;
|
||||
right: 5%;
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
-webkit-transition: 0.4s;
|
||||
transition: 0.4s;
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 18px;
|
||||
width: 26px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: white;
|
||||
-webkit-transition: 0.4s;
|
||||
transition: 0.4s;
|
||||
}
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: $dark-green;
|
||||
}
|
||||
|
||||
input:focus + .slider {
|
||||
box-shadow: 0 0 1px $dark-green;
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
-webkit-transform: translateX(26px);
|
||||
-ms-transform: translateX(26px);
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-ms-flex-wrap: wrap;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -1,128 +0,0 @@
|
|||
const $ = window.$
|
||||
const StripeCheckout = window.StripeCheckout
|
||||
const toastr = window.toastr
|
||||
|
||||
toastr.options = { positionClass: 'toast-top-center' }
|
||||
|
||||
/* top navbar */
|
||||
var nav = $('body > nav')
|
||||
nav.addClass('js')
|
||||
nav.find('.menu').slicknav()
|
||||
nav.find('h4').clone().prependTo('.slicknav_menu')
|
||||
|
||||
/* adding a shadow at the bottom of the menu bar only when not at the top */
|
||||
var w = $(window)
|
||||
w.scroll(function () {
|
||||
var scrollPos = w.scrollTop()
|
||||
if (scrollPos && !nav.hasClass('scrolled')) {
|
||||
nav.addClass('scrolled')
|
||||
} else if (!scrollPos) {
|
||||
nav.removeClass('scrolled')
|
||||
}
|
||||
})
|
||||
|
||||
/* background-color should inherit, but CSS's "inherit" breaks z-index */
|
||||
var bgcolor = $(document.body).css('background-color')
|
||||
if (bgcolor.split(',').length === 4 || bgcolor === 'transparent') {
|
||||
bgcolor = 'white'
|
||||
}
|
||||
nav.css('background-color', bgcolor)
|
||||
|
||||
/* modals -- working with or without JS */
|
||||
function modals () {
|
||||
$('.modal').each(function () {
|
||||
let modal = $(this)
|
||||
modal.addClass('js')
|
||||
let id = modal.attr('id')
|
||||
|
||||
$(`[href="#${id}"]`).click(function (e) {
|
||||
// open the modal
|
||||
e.preventDefault()
|
||||
modal.toggleClass('target')
|
||||
})
|
||||
|
||||
modal.click(function (e) {
|
||||
// close the modal
|
||||
if (e.target === modal[0]) {
|
||||
cleanHash()
|
||||
modal.toggleClass('target')
|
||||
e.preventDefault()
|
||||
}
|
||||
})
|
||||
modal.find('.x a').click(function (e) {
|
||||
// close the modal
|
||||
cleanHash()
|
||||
e.preventDefault()
|
||||
modal.toggleClass('target')
|
||||
})
|
||||
})
|
||||
|
||||
function cleanHash () {
|
||||
if (!window.location.hash) return
|
||||
if (window.history && window.history.replaceState) {
|
||||
window.history.replaceState('', document.title, window.location.pathname)
|
||||
} else {
|
||||
let pos = $(window).scrollTop()
|
||||
window.location.hash = ''
|
||||
$(window).scrollTop(pos)
|
||||
}
|
||||
}
|
||||
|
||||
// activate modals from url hash #
|
||||
setTimeout(() => {
|
||||
// setTimeout is needed because :target elements only appear after
|
||||
// the page is loaded or something like that.
|
||||
let activatedModal = $('*:target')
|
||||
if (activatedModal.length && !activatedModal.is('.target')) {
|
||||
activatedModal.toggleClass('target')
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
modals()
|
||||
|
||||
/* turning flask flash messages into js popup notifications */
|
||||
window.popupMessages.forEach(function (m, i) {
|
||||
var category = m[0] || 'info'
|
||||
var text = m[1]
|
||||
setTimeout(function () { toastr[category](text) }, (1 + i) * 1500)
|
||||
})
|
||||
|
||||
/* stripe checkout */
|
||||
var stripebutton = $('#stripe-upgrade')
|
||||
if (stripebutton.length) {
|
||||
var handler = StripeCheckout.configure(stripebutton.data())
|
||||
stripebutton.on('click', function (e) {
|
||||
handler.open({
|
||||
token: function (token) {
|
||||
stripebutton.closest('form')
|
||||
.append(`<input type="hidden" name="stripeToken" value="${token.id}">`)
|
||||
.append(`<input type="hidden" name="stripeEmail" value="${token.email}">`)
|
||||
.submit()
|
||||
}
|
||||
})
|
||||
e.preventDefault()
|
||||
})
|
||||
}
|
||||
|
||||
/* quick script for showing the resend confirmation form */
|
||||
$('a.resend').on('click', function () {
|
||||
$(this).hide()
|
||||
$('form.resend').show()
|
||||
return false
|
||||
})
|
||||
|
||||
/* scripts at other files */
|
||||
require('./sitewide')()
|
||||
|
||||
/* toggle the card management menu */
|
||||
$(function () {
|
||||
$("#card-list tr:even").addClass("even");
|
||||
$("#card-list tr:not(.even)").hide();
|
||||
$("#card-list tr:first-child").show();
|
||||
|
||||
$("#card-list tr.even").click(function () {
|
||||
$(this).next("tr").toggle();
|
||||
$(this).find(".arrow").toggleClass("up");
|
||||
$(this).find(".fa-chevron-right").toggleClass("fa-rotate-90");
|
||||
});
|
||||
});
|
|
@ -1,57 +0,0 @@
|
|||
var settingsDropDownContent = $("#settings-dropdown-content")[0];
|
||||
|
||||
$(window).click(function(e) {
|
||||
if(!e.target.matches('#settings-button') && e.target.nodeName != 'path' && e.target.nodeName != 'svg') {
|
||||
|
||||
if (settingsDropDownContent.classList.contains("show")) {
|
||||
settingsDropDownContent.classList.remove("show");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$(document).ready(function() {
|
||||
$("#settings-button").click(function() {
|
||||
settingsDropDownContent.classList.toggle("show");
|
||||
});
|
||||
});
|
||||
|
||||
$(document).ready(function() {
|
||||
$(':checkbox').change(function() {
|
||||
targetAttributeBox = this;
|
||||
var target;
|
||||
var checkedStatus = this.checked;
|
||||
var status = $("#status");
|
||||
|
||||
if (this.id == 'recaptcha') {
|
||||
target = '/forms/' + hashid + '/toggle-recaptcha';
|
||||
} else if (this.id == 'email-notifications') {
|
||||
target = '/forms/' + hashid + '/toggle-emails';
|
||||
} else if (this.id == 'submission-storage') {
|
||||
target = '/forms/' + hashid + '/toggle-storage';
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: target,
|
||||
method: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({
|
||||
checked: checkedStatus
|
||||
}),
|
||||
dataType: 'json',
|
||||
beforeSend: function() {
|
||||
status.removeClass("error");
|
||||
status.html('Saving... <i class="fas fa-circle-notch fa-spin"></i>');
|
||||
},
|
||||
success: function (data) {
|
||||
status.html('Saved <i class="fas fa-check-circle"></i>');
|
||||
console.log(data);
|
||||
},
|
||||
error: function (data) {
|
||||
status.html('Error saving <i class="fas fa-times-circle"></i>');
|
||||
status.addClass("error");
|
||||
targetAttributeBox.checked = !checkedStatus;
|
||||
console.log(data);
|
||||
}
|
||||
});
|
||||
})
|
||||
});
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -1,128 +0,0 @@
|
|||
|
||||
.dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
background-color: #f1f1f1;
|
||||
min-width: 160px;
|
||||
overflow: auto;
|
||||
box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.2);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dropdown-content a {
|
||||
color: black;
|
||||
padding: 12px 16px;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#settings-button {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.modal {
|
||||
|
||||
.float-left {
|
||||
float: left;
|
||||
}
|
||||
|
||||
p#status {
|
||||
float: right;
|
||||
color: $dark-blue;
|
||||
font-size: 0.7em;
|
||||
margin-right: 2%;
|
||||
|
||||
&.error {
|
||||
color: $red;
|
||||
}
|
||||
}
|
||||
|
||||
h4.large {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
p.description {
|
||||
line-height: 1.0em;
|
||||
display: inline-block;
|
||||
font-size: 0.8em;
|
||||
margin: 0;
|
||||
margin-top: 0.2em;
|
||||
color: #999999;
|
||||
|
||||
}
|
||||
|
||||
.col-1-6.switch-row {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
height: 26px;
|
||||
right: 5%;
|
||||
//top: 25%;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
-webkit-transition: .4s;
|
||||
transition: .4s;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 26px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: white;
|
||||
-webkit-transition: .4s;
|
||||
transition: .4s;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: $dark-green;
|
||||
}
|
||||
|
||||
input:focus + .slider {
|
||||
box-shadow: 0 0 1px $dark-green;
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
-webkit-transform: translateX(26px);
|
||||
-ms-transform: translateX(26px);
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-ms-flex-wrap: wrap;
|
||||
flex-wrap: wrap;
|
||||
margin-right: -15px;
|
||||
margin-left: -15px;
|
||||
}
|
||||
|
||||
}
|
|
@ -1,51 +0,0 @@
|
|||
<div class="col-1-1">
|
||||
<div class="create-form">
|
||||
{% if current_user.upgraded %}
|
||||
<a href="#create-form" class="button">
|
||||
Create a form
|
||||
</a>
|
||||
|
||||
<div class="modal" id="create-form" aria-hidden="true">
|
||||
<div class="container">
|
||||
<div class="x"><h4>Create form</h4><a href="#">×</a></div>
|
||||
<form method="POST" action="{{ url_for('create-form') }}">
|
||||
<div class="col-1-1">
|
||||
<h4>Send email to:</h4>
|
||||
<input type="email" name="email" placeholder="You can point this form to any email address" value="{{ current_user.email }}">
|
||||
</div>
|
||||
<div class="col-1-1">
|
||||
<h4>From URL:</h4>
|
||||
<input type="url" name="url" placeholder="Leave blank to send confirmation email when first submitted">
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="col-1-4">
|
||||
<label class="hint--bottom" data-hint="A site-wide form is a form that you can place on all pages of your website -- and you just have to confirm once!">
|
||||
<input type="checkbox" name="sitewide" value="true">
|
||||
site-wide
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-3-4 info">
|
||||
When creating a form that is valid site-wide, ensure you have a file named <span class="code">/formspree-verify.txt</span> at the root of your site and that it contains a line with the target email inside.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-1-3">
|
||||
<div class="verify">
|
||||
<button>Verify</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-1-3">
|
||||
​
|
||||
</div>
|
||||
<div class="col-1-3">
|
||||
<div class="create">
|
||||
<button type="submit">Create form</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<h6 class="light">Please <a href="{{ url_for('account') }}">upgrade your account</a> in order to create forms from the dashboard and manage the forms currently associated with your emails.</h6>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1 @@
|
|||
{% extends 'users/dashboard.html' %}
|
|
@ -1,43 +0,0 @@
|
|||
<tr class="{% if form.counter == 0 %}new{% endif %} {% if form.confirmed %}verified{% elif form.confirm_sent %}waiting_confirmation{% endif %}">
|
||||
<td data-label="Status">
|
||||
<a href="#form-{{ form.hashid }}" class="no-underline">
|
||||
{% if not form.host %}
|
||||
<span class="tooltip hint--right" data-hint="New form. Place the code generated here in some page and submit it!"><span class="ion-help"></span></span>
|
||||
{% elif form.confirmed %}
|
||||
<span class="tooltip hint--right" data-hint="Form confirmed"><span class="ion-checkmark-round"></span></span>
|
||||
{% elif form.confirm_sent %}
|
||||
<span class="tooltip hint--right" data-hint="Waiting confirmation"><span class="ion-pause"></span></span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
<td data-label="URL">
|
||||
<a href="#form-{{ form.hashid }}" class="no-underline">
|
||||
{% if not form.host %}
|
||||
Waiting for a submission
|
||||
{% else %}
|
||||
{{ form.host }}
|
||||
{% if form.sitewide %}
|
||||
<span class="highlight tooltip hint--top" data-hint="This form works for all paths under {{ form.host }}/">/ *</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
<td class="target-email" data-label="Target email address">
|
||||
<a href="#form-{{ form.hashid }}" class="no-underline">
|
||||
<span class="hint--top" data-hint="{{ request.url_root.replace('http://', 'https://') }}{% if form.hash %}{{ form.email }}{% else %}{{ form.hashid }}{% endif %}">{{ form.email }}</span>
|
||||
</a>
|
||||
</td>
|
||||
<td class="n-submissions" data-label="Submissions counter">
|
||||
<a href="{{ url_for('form-submissions', hashid=form.hashid) }}" class="no-underline">
|
||||
{% if form.counter == 0 %}
|
||||
<span class="never">never submitted</span>
|
||||
{% else %}
|
||||
{{ form.counter }} submissions
|
||||
{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
<td data-label="Disable"><form method="POST" action="{{ url_for('form-toggle', hashid=form.hashid) }}"><button class="no-border"><i class="fa fa-{% if form.disabled %}lock{% else %}unlock{% endif %} fa-fw"></i></button></form></td>
|
||||
<td data-label="Delete">
|
||||
<a href="#delete-{{form.hashid}}" class="no-underline"><i class="fa fa-trash-o delete"></i></a>
|
||||
</td>
|
||||
</tr>
|
|
@ -1,87 +0,0 @@
|
|||
{% extends 'users/dashboard.html' %}
|
||||
|
||||
{% block sectiontitle %}
|
||||
<h1>Your forms</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block section %}
|
||||
|
||||
<div class="col-1-1">
|
||||
<h4>Active Forms</h4>
|
||||
<table class="forms responsive">
|
||||
{# status icon | host or form link | target email | number of submissions #}
|
||||
{% for form in enabled_forms %}
|
||||
{% include "forms/list-item.html" %}
|
||||
{% else %}
|
||||
<p>No active forms found. Forms can be enabled by clicking the unlock icon below.</p>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
{% if disabled_forms %}
|
||||
<h4>Disabled Forms</h4>
|
||||
<table class="forms responsive">
|
||||
{# status icon | host or form link | target email | number of submissions #}
|
||||
{% for form in disabled_forms %}
|
||||
{% include "forms/list-item.html" %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if not enabled_forms and not disabled_forms and current_user.upgraded %}
|
||||
<h6 class="light">You don't have any forms associated with this account, maybe you should <a
|
||||
href="{{ url_for('account') }}">verify your email</a>.</h6>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% include "forms/create.html" %}
|
||||
|
||||
{# modals for each form #}
|
||||
{% for form in (enabled_forms + disabled_forms) %}
|
||||
<div class="modal" id="form-{{ form.hashid }}" aria-hidden="true">
|
||||
<div>
|
||||
{% if request.args.get('new') == form.hashid %}
|
||||
<div class="x"><h4>Congratulations!</h4><a href="#">×</a></div>
|
||||
<p style="clear: both">Now that you have created your form, use the following HTML code in your website, filling it with the <span class="code"><input></span> fields you wish your visitors to fill:</p>
|
||||
{% else %}
|
||||
<div class="x"><h4>Use this code in your HTML</h4><a href="#">×</a></div>
|
||||
{% endif %}
|
||||
<div class="html-highlight">
|
||||
<span class="bracket"><</span><span class="tagname">form</span>
|
||||
<span class="attrkey">action</span><span class="equal">=</span><span
|
||||
class="attrvalue">"{{ config.SERVICE_URL }}/{{ form.hashid }}"</span>
|
||||
<span class="attrkey">method</span><span class="equal">=</span><span class="attrvalue">"POST"</span><span
|
||||
class="bracket">></span>
|
||||
<br>
|
||||
<span class="bracket"><</span><span class="tagname">input</span>
|
||||
<span class="attrkey">type</span><span class="equal">=</span><span class="attrvalue">"text"</span>
|
||||
<span class="attrkey">name</span><span class="equal">=</span><span class="attrvalue">"_gotcha"</span>
|
||||
<span class="attrkey">style</span><span class="equal">=</span><span
|
||||
class="attrvalue">"display: none"</span><span class="bracket">></span>
|
||||
<br>
|
||||
<br>
|
||||
<span class="comment"><!-- your other form fields go here --></span>
|
||||
<br>
|
||||
<br>
|
||||
<span class="bracket"><</span><span class="tagname">button</span>
|
||||
<span class="attrkey">type</span><span class="equal">=</span><span class="attrvalue">"submit"</span><span class="bracket">></span>Send<span class="bracket"></</span><span class="tagname">button</span><span class="bracket">></span>
|
||||
<br>
|
||||
<span class="bracket"></</span><span class="tagname">form</span><span class="bracket">></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="delete-{{ form.hashid }}" aria-hidden="true">
|
||||
<div>
|
||||
<div class="x"><h4>Are you ABSOLUTELY sure?</h4><a href="#">×</a></div>
|
||||
<br/>
|
||||
<p>This action <strong>CANNOT</strong> be undone. This will permanently delete the form{% if form.host %} on
|
||||
<strong>{{ form.host }}</strong>{% endif %} and all related submissions.</p>
|
||||
<form method="POST" action="{{ url_for('form-deletion', hashid=form.hashid) }}">
|
||||
<span class="cancel"><button type="button" onclick="return false;">Cancel</button></span>
|
||||
<button class="delete">Confirm deletion</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
|
@ -1,131 +0,0 @@
|
|||
{% extends 'users/dashboard.html' %}
|
||||
|
||||
{% block sectiontitle %}
|
||||
<h1>Submission History</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block section %}
|
||||
|
||||
<div class="col-1-1 submissions-col">
|
||||
<h2>
|
||||
Submissions for
|
||||
{% if not form.hash %}
|
||||
<span class="code">/{{ form.hashid }}</span>
|
||||
{% else %}
|
||||
<span class="code">/{{ form.email }}</span>
|
||||
{% endif %}
|
||||
at <span class="code">{{ form.host }}</span>{% if form.sitewide %} and all its subpaths.{% endif %}
|
||||
{% if not form.hash %}
|
||||
<br><small>targeting <span class="code">{{ form.email }}</span></small>
|
||||
{% else %}
|
||||
<br><small>you can now replace the email in the URL with <span class="code">/{{ form.hashid }}</span></small>
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% if submissions %}
|
||||
<table class="submissions responsive">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Submitted at</th>
|
||||
{% for f in fields %}
|
||||
<th>{{ f }}</th>
|
||||
{% endfor %}
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in submissions %}
|
||||
<tr id="submission-{{ s.id }}">
|
||||
<td id="p-{{ s.id }}" data-label="Submitted at">{{ s.submitted_at.strftime('%A, %B %d, %Y at %X') }} UTC</td>
|
||||
{% for f in fields %}
|
||||
{% set value = s.data[f] or '' %}
|
||||
<td data-label="{{ f }}">
|
||||
{% if 320 < (value | length) %}
|
||||
<pre>{{ value[:270] }}… <a href=#submission-{{ s.id }}>(see more)</a></pre>
|
||||
<pre class="full">{{ value }} <a href=#p-{{ s.id }}>(see less)</a></pre>
|
||||
{% else %}
|
||||
<pre>{{ value }}</pre>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
<td><form method="POST" action="{{ url_for('submission-deletion', hashid=form.hashid, submissionid=s.id) }}"><button class="no-border"><i class="fa fa-trash-o delete"></i></button></form></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<h3>No submissions archived yet.</h3>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="container block">
|
||||
<div class="col-1-2 left">
|
||||
<a href="#create-form" class="button">
|
||||
<i class="fas fa-cogs"></i> Settings
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-1-2 right">
|
||||
<div class="dropdown">
|
||||
<a id="settings-button" class="button">Export <i class="fas fa-arrow-circle-down" id="settings-gears"></i></a>
|
||||
<div id="settings-dropdown-content" class="dropdown-content">
|
||||
<a href="{{ url_for('form-submissions', hashid=form.hashid, format='csv') }}" target="_blank">Export as CSV</a>
|
||||
<a href="{{ url_for('form-submissions', hashid=form.hashid, format='json') }}" target="_blank">Export as JSON</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="create-form" aria-hidden="true">
|
||||
<div class="container">
|
||||
<div class="x"><h4 class="large">Form Settings</h4><a href="#">×</a></div>
|
||||
<div class="row">
|
||||
<div class="col-5-6">
|
||||
<div><h4>reCAPTCHA</h4></div>
|
||||
|
||||
<p class="description">reCAPTCHA provides vital spam protection. You can turn off reCAPTCHA to remove the intermediate screen.</p>
|
||||
|
||||
</div>
|
||||
<div class="col-1-6 switch-row">
|
||||
<div><label class="switch">
|
||||
<input type="checkbox"{% if not form.captcha_disabled %} checked{% endif %} id="recaptcha">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-5-6">
|
||||
<h4>Email Notifications</h4>
|
||||
<p class="description">You can disable email notifications if you want to download all submissions from the Formspree dashboard.</p>
|
||||
</div>
|
||||
<div class="col-1-6 switch-row">
|
||||
<label class="switch">
|
||||
<input type="checkbox"{% if not form.disable_email %} checked{% endif %} id="email-notifications">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-5-6">
|
||||
<h4>Submission Archive</h4>
|
||||
<p class="description">You can disable the submission archive if you don't want Formspree to store your submissions.</p>
|
||||
</div>
|
||||
<div class="col-1-6 switch-row">
|
||||
<label class="switch">
|
||||
<input type="checkbox"{% if not form.disable_storage %} checked{% endif %} id="submission-storage">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="status">Saved <i class="fas fa-check-circle"></i></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
const hashid = '{{ form.hashid }}';
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/submissions.js') }}"></script>
|
||||
|
||||
{% endblock %}
|
|
@ -22,18 +22,15 @@
|
|||
<!-- end google optimize and analytics -->
|
||||
<script src="//use.typekit.net/{{config.TYPEKIT_KEY}}.js"></script>
|
||||
<script>try{Typekit.load();}catch(e){}</script>
|
||||
<link rel="stylesheet" href="{{url_for('static', filename='css/main.css')}}">
|
||||
<link rel="icon" type="image/png" href="{{config.SERVICE_URL}}/static/img/favicon.ico">
|
||||
<meta name="viewport" content="initial-scale=1">
|
||||
<script defer src="https://use.fontawesome.com/releases/v5.0.9/js/all.js"></script>
|
||||
<script defer src="https://use.fontawesome.com/releases/v5.0.9/js/v4-shims.js"></script>
|
||||
{# <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.min.css">#}
|
||||
{#<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.9/css/all.css" integrity="sha384-5SOiIsAziJl6AWe0HWRKTXlfcSHKmYV4RBF18PPJ173Kzn7jzMyFuTtk8JA7QQG1" crossorigin="anonymous">#}
|
||||
|
||||
|
||||
<script src="{{ url_for('static', filename='js/vendor/jquery.min.js') }}"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
|
||||
<script type="text/javascript" src="https://js.stripe.com/v3/"></script>
|
||||
{% block head_scripts %}{% endblock %}
|
||||
<link rel="stylesheet" href="{{url_for('static', filename='main.css')}}">
|
||||
</head>
|
||||
<body class="{% block bodyclass %}{% endblock %}" id="{% block bodyid %}card{% endblock %}">
|
||||
|
||||
|
@ -65,7 +62,7 @@
|
|||
</div>
|
||||
<div class="menu">
|
||||
{% if request.path != '/' %}<span class="item"><a href="/">Home</a></span>{% endif %}
|
||||
<span class="item"><a href="{{ url_for('forms') }}">Your forms</a></span>
|
||||
<span class="item"><a href="{{ url_for('dashboard') }}">Your forms</a></span>
|
||||
<span class="item"><a href="{{ url_for('account') }}">{% if current_user.upgraded %}Your{% else %}Upgrade{% endif %} account</a></span>
|
||||
{% else %}
|
||||
<div class="greetings">
|
||||
|
@ -104,6 +101,6 @@
|
|||
<script type="text/javascript">
|
||||
Stripe.setPublishableKey('{{config.STRIPE_PUBLISHABLE_KEY}}');
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/bundle.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='bundle.js' if config.DEBUG else 'bundle.prod.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
{% extends 'static_pages/index.html' %}
|
||||
|
||||
{% block head_scripts %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/6.26.0/polyfill.min.js"></script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.39.2/codemirror.min.css" rel="stylesheet"></script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.39.2/theme/oceanic-next.css" rel="stylesheet"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block bodyclass %}dashboard{% endblock%}
|
||||
|
||||
{% block base %}
|
||||
|
|
65
package.json
65
package.json
|
@ -1,24 +1,55 @@
|
|||
{
|
||||
"babel": {
|
||||
"presets": [
|
||||
"es2015"
|
||||
"dependencies": {
|
||||
"class-set": "^0.0.4",
|
||||
"codemirror": "^5.39.2",
|
||||
"is-valid-email": "0.0.2",
|
||||
"react": "^16.4.2",
|
||||
"react-codemirror2": "^5.1.0",
|
||||
"react-dom": "^16.4.2",
|
||||
"react-router-dom": "^4.3.1",
|
||||
"url": "^0.11.0",
|
||||
"valid-url": "^1.0.9"
|
||||
},
|
||||
"browserify": {
|
||||
"transform": [
|
||||
[
|
||||
"babelify",
|
||||
{
|
||||
"sourceMaps": true,
|
||||
"sourceMapsAbsolute": true
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"is-valid-email": "0.0.2",
|
||||
"url": "^0.11.0",
|
||||
"valid-url": "^1.0.9",
|
||||
"virtual-dom": "^2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-core": "^6.6.5",
|
||||
"babel-preset-es2015": "^6.6.0",
|
||||
"babelify": "^7.2.0",
|
||||
"browserify": "^13.0.0"
|
||||
"@babel/core": "^7.0.0-rc.1",
|
||||
"@babel/preset-env": "^7.0.0-rc.1",
|
||||
"@babel/preset-react": "^7.0.0-rc.1",
|
||||
"babelify": "^9.0.0",
|
||||
"browserify": "^16.2.2",
|
||||
"envify": "^4.1.0",
|
||||
"prettier": "1.14.2",
|
||||
"uglify-js": "^3.4.7",
|
||||
"uglifyify": "^5.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build-js": "browserify formspree/static/js/main.js -t babelify --outfile formspree/static/js/bundle.js",
|
||||
"build-css": "scss --sourcemap=none formspree/static/scss/main.scss formspree/static/css/main.css",
|
||||
"build": "npm run build-js && npm run build-css"
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"arrowParens": "avoid",
|
||||
"insertPragma": true,
|
||||
"printWidth": 80,
|
||||
"proseWrap": "preserve",
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"useTabs": false,
|
||||
"jsxBracketSameLine": false,
|
||||
"bracketSpacing": false
|
||||
},
|
||||
"eslintConfig": {
|
||||
"overrides": {
|
||||
"files": [
|
||||
"*"
|
||||
],
|
||||
"rules": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue