Merge pull request #201 from formspree/whitelabel-custom-email

Whitelabel custom email
This commit is contained in:
Cole 2018-09-23 12:06:53 -04:00 committed by GitHub
commit e45370ebc9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1509 additions and 788 deletions

View File

@ -19,9 +19,9 @@ hashids = "==1.2.0"
psycopg2 = "==2.7.4"
redis = "==2.10.5"
requests = "==2.1.0"
SQLAlchemy = "==1.0.13"
stripe = "==1.55.0"
structlog = "==16.1.0"
SQLAlchemy = "==1.0.13"
Flask = "==1"
Flask-CDN = "==1.5.3"
Flask-Cors = "==3.0.2"
@ -34,6 +34,7 @@ unicodecsv = "==0.14.1"
flask-migrate = "==2.2.1"
premailer = "==3.2.0"
ptvsd = "==4.1.2"
pystache = "*"
[pipenv]
allow_prereleases = true

9
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "0beec9c65d249913687cbf70f560a64c506f6f3bfa339c178bd90c4d30efb986"
"sha256": "bfe328625ab39aac4b6c31f49f6cd5b34e4b880dc924e2f85e729d5f1a8a3368"
},
"pipfile-spec": 6,
"requires": {
@ -292,6 +292,13 @@
"index": "pypi",
"version": "==4.1.2"
},
"pystache": {
"hashes": [
"sha256:f7bbc265fb957b4d6c7c042b336563179444ab313fb93a719759111eabd3b85a"
],
"index": "pypi",
"version": "==0.5.4"
},
"python-dateutil": {
"hashes": [
"sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0",

View File

@ -1,3 +1,5 @@
import datetime
from urllib.parse import urljoin
from flask import request, jsonify, g
@ -8,13 +10,13 @@ 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
from .models import Form, Submission, EmailTemplate
@login_required
def list():
# grab all the forms this user controls
if current_user.upgraded:
if current_user.has_feature('dashboard'):
forms = current_user.forms.order_by(Form.id.desc()).all()
else:
forms = []
@ -22,8 +24,8 @@ def list():
return jsonify({
'ok': True,
'user': {
'upgraded': current_user.upgraded,
'email': current_user.email
'features': {f: True for f in current_user.features},
'email': current_user.email
},
'forms': [f.serialize() for f in forms]
})
@ -37,8 +39,8 @@ def create():
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.')
if not current_user.has_feature('dashboard'):
g.log.info('Failed to create form from dashboard. Forbidden.')
return jsonerror(402, {'error': "Please upgrade your account."})
email = request.get_json().get('email')
@ -97,16 +99,14 @@ def create():
@login_required
def get(hashid):
if not current_user.upgraded:
if not current_user.has_feature('dashboard'):
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:
if not form.controlled_by(current_user):
return jsonerror(401, {'error': "You do not control this form."})
submissions, fields = form.submissions_with_fields()
@ -193,6 +193,35 @@ def submission_delete(hashid, submissionid):
return jsonify({'ok': True})
@login_required
def custom_template_set(hashid):
form = Form.get_with_hashid(hashid)
if not form:
return jsonerror(404, {'error': "Form not found."})
if not form.controlled_by(current_user):
return jsonerror(401, {'error': "You do not control this form."})
template = form.template
if not template:
template = EmailTemplate(form_id=form.id)
template.from_name = request.get_json()['from_name']
template.subject = request.get_json()['subject']
template.style = request.get_json()['style']
template.body = request.get_json()['body']
try:
template.sample()
except Exception as e:
print(e)
return jsonerror(406, {'error': "Failed to render. The template has errors."})
DB.session.add(template)
DB.session.commit()
return jsonify({'ok': True})
@login_required
def sitewide_check():
email = request.get_json().get('email')

View File

@ -2,18 +2,23 @@ import hmac
import random
import hashlib
import datetime
import pystache
from flask import url_for, render_template, render_template_string, g
from sqlalchemy.sql import table
from sqlalchemy.sql.expression import delete
from sqlalchemy.dialects.postgresql import JSON
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy import func
from werkzeug.datastructures import ImmutableMultiDict, \
ImmutableOrderedMultiDict
from premailer import transform
from formspree import settings
from formspree.stuff import DB, redis_store, TEMPLATES
from formspree.utils import send_email, unix_time_for_12_months_from_now, \
next_url, IS_VALID_EMAIL, request_wants_json
from formspree.users.models import Plan
from .helpers import HASH, HASHIDS_CODEC, REDIS_COUNTER_KEY, \
http_form_to_dict, referrer_to_path, \
store_first_submission, fetch_first_submission, \
@ -40,6 +45,7 @@ class Form(DB.Model):
owner = DB.relationship('User') # direct owner, defined by 'owner_id'
# this property is basically useless. use .controllers
template = DB.relationship('EmailTemplate', uselist=False, back_populates='form')
submissions = DB.relationship('Submission',
backref='form', lazy='dynamic', order_by=lambda: Submission.id.desc())
@ -104,9 +110,18 @@ class Form(DB.Model):
return by_email.union(by_creation)
@property
def upgraded(self):
upgraded_controllers = [i for i in self.controllers if i.upgraded]
return len(upgraded_controllers) > 0
def features(self):
return set.union(*[cont.features for cont in self.controllers])
def controlled_by(self, user):
for cont in self.controllers:
if cont.id == user.id:
return True
return False
def has_feature(self, feature):
c = [user for user in self.controllers if user.has_feature(feature)]
return len(c) > 0
@classmethod
def get_with_hashid(cls, hashid):
@ -124,6 +139,8 @@ class Form(DB.Model):
'counter': self.counter,
'email': self.email,
'host': self.host,
'template': self.template.serialize() if self.template else None,
'features': {f: True for f in self.features},
'confirm_sent': self.confirm_sent,
'confirmed': self.confirmed,
'disabled': self.disabled,
@ -176,6 +193,7 @@ class Form(DB.Model):
next = next_url(referrer, data.get('_next'))
spam = data.get('_gotcha', None)
format = data.get('_format', None)
from_name = None
# turn cc emails into array
if cc:
@ -207,8 +225,8 @@ class Form(DB.Model):
# increment the forms counter
self.counter = Form.counter + 1
# if submission storage is disabled and form is upgraded, don't store submission
if self.disable_storage and self.upgraded:
# if submission storage is disabled, don't store submission
if self.disable_storage and self.has_feature('dashboard'):
pass
else:
DB.session.add(self)
@ -239,17 +257,18 @@ class Form(DB.Model):
# url to request_unconfirm_form page
unconfirm = url_for('request_unconfirm_form', form_id=self.id, _external=True)
# check if the forms are over the counter and the user is not upgraded
# check if the forms are over the counter and the user has unlimited submissions
overlimit = False
monthly_counter = self.get_monthly_counter()
monthly_limit = settings.MONTHLY_SUBMISSIONS_LIMIT \
if self.id > settings.FORM_LIMIT_DECREASE_ACTIVATION_SEQUENCE \
else settings.GRANDFATHER_MONTHLY_LIMIT
if monthly_counter > monthly_limit and not self.upgraded:
if monthly_counter > monthly_limit and not self.has_feature('unlimited'):
overlimit = True
if monthly_counter == int(monthly_limit * 0.9) and not self.upgraded:
if monthly_counter == int(monthly_limit * 0.9) and \
not self.has_feature('unlimited'):
# send email notification
send_email(
to=self.email,
@ -271,8 +290,15 @@ class Form(DB.Model):
text = render_template('email/form.txt',
data=data, host=self.host, keys=keys, now=now,
unconfirm_url=unconfirm)
# check if the user wants a new or old version of the email
if format == 'plain':
# if there's a custom email template we should use it
# otherwise check if the user wants a new or old version of the email
if self.owner and self.owner.has_feature('whitelabel') and self.template:
html, subject = self.template.render_body_and_subject(
data=data, host=self.host, keys=keys, now=now,
unconfirm_url=unconfirm)
from_name = self.template.from_name
elif format == 'plain':
html = render_template('email/plain_form.html',
data=data, host=self.host, keys=keys, now=now,
unconfirm_url=unconfirm)
@ -295,8 +321,8 @@ class Form(DB.Model):
else:
return {'code': Form.STATUS_OVERLIMIT}
# if emails are disabled and form is upgraded, don't send email notification
if self.disable_email and self.upgraded:
# if emails are disabled, don't send email notification
if self.disable_email and self.has_feature('dashboard'):
return {'code': Form.STATUS_NO_EMAIL, 'next': next}
else:
result = send_email(
@ -305,6 +331,7 @@ class Form(DB.Model):
text=text,
html=html,
sender=settings.DEFAULT_SENDER,
from_name=from_name,
reply_to=reply_to,
cc=cc,
headers={
@ -483,15 +510,81 @@ class Form(DB.Model):
return True
from sqlalchemy.dialects.postgresql import JSON
from sqlalchemy.ext.mutable import MutableDict
class EmailTemplate(DB.Model):
__tablename__ = 'email_templates'
id = DB.Column(DB.Integer, primary_key=True)
form_id = DB.Column(
DB.Integer, DB.ForeignKey('forms.id'),
unique=True, nullable=False
)
subject = DB.Column(DB.Text, nullable=False)
from_name = DB.Column(DB.Text, nullable=False)
style = DB.Column(DB.Text, nullable=False)
body = DB.Column(DB.Text, nullable=False)
form = DB.relationship('Form', back_populates='template')
def __init__(self, form_id):
self.submitted_at = datetime.datetime.utcnow()
self.form_id = form_id
def __repr__(self):
return '<Email Template %s, form=%s>' % \
(self.id or 'with an id to be assigned', self.form_id)
@classmethod
def make_sample(cls, style, body,
from_name='Formspree Team',
subject='New submission from {{ _host }}'):
t = cls(0)
t.from_name = from_name
t.subject = subject
t.style = style
t.body = body
return t.sample()
def sample(self):
return self.render_body_and_subject(
data={
'name': 'Irwin Jones',
'_replyto': 'i.jones@example.com',
'message': 'Hello!\n\nThis is a preview message!'
},
host='example.com/',
keys=['name', '_replyto', 'message'],
now=datetime.datetime.utcnow().strftime('%I:%M %p UTC - %d %B %Y'),
unconfirm_url='#'
)
def serialize(self):
return {
'subject': self.subject,
'from_name': self.from_name,
'style': self.style,
'body': self.body
}
def render_body_and_subject(self, data, host, keys, now, unconfirm_url):
data.update({
'_fields': [{'_name': f, '_value': data[f]} for f in keys],
'_time': now,
'_host': host
})
subject = pystache.render(self.subject, data)
html = pystache.render('<style>' + self.style + '</style>' + self.body, data)
print(html)
inlined = transform(html)
suffixed = inlined + '''<table width="100%"><tr><td>You are receiving this because you confirmed this email address on <a href="{service_url}">{service_name}</a>. If you don't remember doing that, or no longer wish to receive these emails, please remove the form on {host} or <a href="{unconfirm_url}">click here to unsubscribe</a> from this endpoint.</td></tr></table>'''.format(service_url=settings.SERVICE_URL, service_name=settings.SERVICE_NAME, host=host, unconfirm_url=unconfirm_url)
return suffixed, subject
class Submission(DB.Model):
__tablename__ = 'submissions'
id = DB.Column(DB.Integer, primary_key=True)
submitted_at = DB.Column(DB.DateTime)
form_id = DB.Column(DB.Integer, DB.ForeignKey('forms.id'))
form_id = DB.Column(DB.Integer, DB.ForeignKey('forms.id'), nullable=False)
data = DB.Column(MutableDict.as_mutable(JSON))
def __init__(self, form_id):

View File

@ -4,6 +4,7 @@ import requests
import datetime
import io
from urllib.parse import urljoin
from lxml.html import rewrite_links
from flask import request, url_for, render_template, redirect, \
jsonify, flash, make_response, Response, g, \
@ -20,7 +21,7 @@ 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, KEYS_EXCLUDED_FROM_EMAIL
from .models import Form, Submission
from .models import Form, Submission, EmailTemplate
def thanks():
@ -195,8 +196,8 @@ def send(email_or_string):
captcha_verified or
settings.TESTING)
# if form is upgraded check if captcha is disabled
if form.upgraded:
# check if captcha is disabled
if form.has_feature('dashboard'):
needs_captcha = needs_captcha and not form.captcha_disabled
if needs_captcha:
@ -490,15 +491,25 @@ def serve_dashboard(hashid=None, s=None):
return render_template('forms/dashboard.html')
@login_required
def custom_template_preview_render():
body, _ = EmailTemplate.make_sample(
from_name=request.args.get('from_name'),
subject=request.args.get('subject'),
style=request.args.get('style'),
body=request.args.get('body'),
)
return rewrite_links(body, lambda x: "#" + x)
@login_required
def export_submissions(hashid, format=None):
if not current_user.upgraded:
if not current_user.has_feature('dashboard'):
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 not form.controlled_by(current_user):
return abort(401)
submissions, fields = form.submissions_with_fields()

View File

@ -36,18 +36,6 @@ module.exports = class CreateForm extends React.Component {
}
render() {
if (!this.props.user.upgraded) {
return (
<div className="col-1-1 create-form">
<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>
</div>
)
}
let {
email,
url: urlv,

View File

@ -50,6 +50,8 @@ module.exports = class FormList extends React.Component {
}
render() {
let {user, enabled_forms, disabled_forms} = this.state
if (this.state.loading) {
return (
<div className="col-1-1">
@ -69,6 +71,18 @@ module.exports = class FormList extends React.Component {
)
}
if (!user.features.dashboard) {
return (
<div className="col-1-1">
<p>
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.
</p>
</div>
)
}
return (
<>
<Portal to=".menu .item:nth-child(2)">
@ -79,10 +93,10 @@ module.exports = class FormList extends React.Component {
</Portal>
<div className="col-1-1">
<h4>Active Forms</h4>
{this.state.enabled_forms.length ? (
{enabled_forms.length ? (
<table className="forms responsive">
<tbody>
{this.state.enabled_forms.map(form => (
{enabled_forms.map(form => (
<FormItem key={form.hashid} {...form} />
))}
</tbody>
@ -94,12 +108,12 @@ module.exports = class FormList extends React.Component {
</p>
)}
{this.state.disabled_forms.length ? (
{disabled_forms.length ? (
<>
<h4>Disabled Forms</h4>
<table className="forms responsive">
<tbody>
{this.state.disabled_forms.map(form => (
{disabled_forms.map(form => (
<FormItem key={form.hashid} {...form} />
))}
</tbody>
@ -107,9 +121,9 @@ module.exports = class FormList extends React.Component {
</>
) : null}
{this.state.enabled_forms.length === 0 &&
this.state.disabled_forms.length === 0 &&
this.state.user.upgraded ? (
{enabled_forms.length === 0 &&
disabled_forms.length === 0 &&
user.features.dashboard ? (
<h6 className="light">
You don't have any forms associated with this account, maybe you
should <a href="/account">verify your email</a>.
@ -117,7 +131,7 @@ module.exports = class FormList extends React.Component {
) : null}
</div>
<CreateForm user={this.state.user} {...this.props} />
<CreateForm user={user} {...this.props} />
</>
)
}
@ -200,6 +214,30 @@ class FormItem extends React.Component {
</Link>
)}
</td>
<td>
{form.features.whitelabel && (
<Link
to={`/forms/${form.hashid}/whitelabel`}
className="no-underline"
>
{form.template ? (
<span
className="tooltip hint--top"
data-hint="Uses a custom email template."
>
<span className="ion-document-text" />
</span>
) : (
<span
className="tooltip hint--top"
data-hint="Doesn't use a custom email template."
>
<span className="ion-document" />
</span>
)}
</Link>
)}
</td>
<td>
<Link to={`/forms/${form.hashid}/settings`} className="no-underline">
<span className="ion-gear-b" />

View File

@ -1,675 +0,0 @@
/** @format */
const toastr = window.toastr
const fetch = window.fetch
const React = require('react')
const {Route, Link, NavLink, Redirect} = require('react-router-dom')
const CodeMirror = require('react-codemirror2')
const cs = require('class-set')
require('codemirror/mode/xml/xml')
require('codemirror/mode/javascript/javascript')
const Portal = require('../Portal')
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 (
<>
<Portal to=".menu .item:nth-child(2)">
<Link to="/forms">Your forms</Link>
</Portal>
<Portal to="#header .center">
<h1>Form Details</h1>
</Portal>
<Route
exact
path={`/forms/${hashid}`}
render={() => <Redirect to={`/forms/${hashid}/submissions`} />}
/>
{this.state.form && (
<>
<h4 className="tabs">
<NavLink
to={`/forms/${hashid}/integration`}
activeStyle={{color: 'inherit', cursor: 'normal'}}
>
Integration
</NavLink>
<NavLink
to={`/forms/${hashid}/submissions`}
activeStyle={{color: 'inherit', cursor: 'normal'}}
>
Submissions
</NavLink>
<NavLink
to={`/forms/${hashid}/settings`}
activeStyle={{color: 'inherit', cursor: 'normal'}}
>
Settings
</NavLink>
</h4>
<Route
path="/forms/:hashid/integration"
render={() => (
<FormIntegration
form={this.state.form}
onUpdate={this.fetchForm}
/>
)}
/>
<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 resp = await fetch(`/api-int/forms/${hashid}`, {
credentials: 'same-origin',
headers: {Accept: 'application/json'}
})
let r = await resp.json()
if (!resp.ok || r.error) {
toastr.warning(
r.error ? `Error fetching form: ${r.error}` : 'Error fetching form.'
)
return
}
this.setState({form: r})
} catch (e) {
console.error(e)
toastr.error(`Failed to fetch form, see the console for more details.`)
}
}
}
class FormIntegration extends React.Component {
constructor(props) {
super(props)
this.changeTab = this.changeTab.bind(this)
this.state = {
activeTab: 'HTML',
availableTabs: ['HTML', 'AJAX']
}
}
render() {
let {form} = this.props
var codeSample
var modeSample
switch (this.state.activeTab) {
case 'HTML':
modeSample = 'xml'
codeSample = `<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>`
break
case 'AJAX':
modeSample = 'javascript'
codeSample = `// There should be an HTML form elsewhere on the page. See the "HTML" tab.
var form = document.querySelector('form')
var data = new FormData(form)
var req = new XMLHttpRequest()
req.open(form.method, form.action)
req.send(data)`
break
}
var integrationSnippet
if (this.state.activeTab === 'AJAX' && !form.captcha_disabled) {
integrationSnippet = (
<div className="integration-nocode CodeMirror cm-s-oceanic-next">
<p>Want to submit your form through AJAX?</p>
<p>
<Link to={`/forms/${form.hashid}/settings`}>Disable reCAPTCHA</Link>{' '}
for this form to make it possible!
</p>
</div>
)
} else {
integrationSnippet = (
<CodeMirror.UnControlled
value={codeSample}
options={{
theme: 'oceanic-next',
mode: modeSample,
viewportMargin: Infinity
}}
/>
)
}
return (
<>
<div className="col-1-1">
<div className="container">
<div className="integration">
<p>
Paste this code in your HTML, modifying it according to your
needs:
</p>
<div className="integration-tabs">
{this.state.availableTabs.map(tabName => (
<div
key={tabName}
data-tab={tabName}
onClick={this.changeTab}
className={cs({active: this.state.activeTab === tabName})}
>
{tabName}
</div>
))}
</div>
{integrationSnippet}
</div>
</div>
</div>
</>
)
}
changeTab(e) {
e.preventDefault()
this.setState({activeTab: e.target.dataset.tab})
}
}
class FormSubmissions extends React.Component {
constructor(props) {
super(props)
this.deleteSubmission = this.deleteSubmission.bind(this)
this.showExportButtons = this.showExportButtons.bind(this)
this.state = {
exporting: false
}
}
render() {
let {form} = this.props
return (
<div className="col-1-1 submissions-col">
<FormDescription prefix="Submissions for" form={form} />
{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>
<div className="container">
<div className="row">
{this.state.exporting ? (
<div className="col-1-1 right">
<a
target="_blank"
className="button"
style={{marginRight: '5px'}}
href={`/forms/${form.hashid}.json`}
>
Export as JSON
</a>
<a
target="_blank"
className="button"
href={`/forms/${form.hashid}.csv`}
>
Export as CSV
</a>
</div>
) : (
<div className="col-1-1 right">
<button
onClick={this.showExportButtons}
href={`/forms/${form.hashid}.json`}
>
Export
</button>
</div>
)}
</div>
</div>
</>
) : (
<h3>No submissions archived yet.</h3>
)}
</div>
)
}
showExportButtons(e) {
e.preventDefault()
this.setState({exporting: true})
}
async deleteSubmission(e) {
e.preventDefault()
let subid = e.currentTarget.dataset.sub
try {
let resp = await fetch(
`/api-int/forms/${this.props.form.hashid}/submissions/${subid}`,
{
method: 'DELETE',
credentials: 'same-origin',
headers: {Accept: 'application/json'}
}
)
let r = await resp.json()
if (!resp.ok || r.error) {
toastr.warning(
r.error
? `Failed to delete submission: ${r.error}`
: `Failed to delete submission.`
)
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,
temporaryFormChanges: {}
}
}
render() {
let {form} = this.props
let tmp = this.state.temporaryFormChanges
return (
<>
<div className="col-1-1" id="settings">
<FormDescription prefix="Settings for" form={form} />
<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={'disabled' in tmp ? !tmp.disabled : !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={
'captcha_disabled' in tmp
? !tmp.captcha_disabled
: !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={
'disable_email' in tmp
? !tmp.disable_email
: !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={
'disable_storage' in tmp
? !tmp.disable_storage
: !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) {
let attr = e.currentTarget.name
let val = !e.currentTarget.checked
this.setState(state => {
state.temporaryFormChanges[attr] = val
return state
})
try {
let resp = await fetch(`/api-int/forms/${this.props.form.hashid}`, {
method: 'PATCH',
body: JSON.stringify({
[attr]: val
}),
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
})
let r = await resp.json()
if (!resp.ok || r.error) {
toastr.warning(
r.error
? `Failed to save settings: ${r.error}`
: 'Failed to save settings.'
)
return
}
toastr.success('Settings saved.')
this.props.onUpdate().then(() => {
this.setState({temporaryFormChanges: {}})
})
} catch (e) {
console.error(e)
toastr.error('Failed to update form. See the console for more details.')
this.setState({temporaryFormChanges: {}})
}
}
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 resp = await fetch(`/api-int/forms/${this.props.form.hashid}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: {
Accept: 'application/json'
}
})
let r = await resp.json()
if (resp.error || r.error) {
toastr.warning(
r.error
? `failed to delete form: ${r.error}`
: 'failed to delete form.'
)
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.')
}
}
}
function FormDescription({prefix, form}) {
return (
<h2 className="form-description">
{prefix}{' '}
{!form.hash ? (
<span className="code">/{form.hashid}</span>
) : (
<span className="code">/{form.email}</span>
)}{' '}
{form.host ? (
<>
at <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>
)
}

View File

@ -0,0 +1,18 @@
/** @format */
const React = require('react') // eslint-disable-line no-unused-vars
module.exports = FormDescription
function FormDescription({prefix, form}) {
return (
<h2 className="form-description">
{prefix}{' '}
{!form.hash ? (
<span className="code">/{form.hashid}</span>
) : (
<span className="code">/{form.email}</span>
)}
</h2>
)
}

View File

@ -0,0 +1,119 @@
/** @format */
const React = require('react')
const CodeMirror = require('react-codemirror2')
const cs = require('class-set')
require('codemirror/mode/xml/xml')
require('codemirror/mode/javascript/javascript')
const {Link} = require('react-router-dom')
module.exports = class FormIntegration extends React.Component {
constructor(props) {
super(props)
this.changeTab = this.changeTab.bind(this)
this.availableTabs = ['HTML', 'AJAX']
this.state = {
activeTab: 'HTML'
}
}
render() {
let {form} = this.props
var codeSample
var modeSample
switch (this.state.activeTab) {
case 'HTML':
modeSample = 'xml'
codeSample = `<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>`
break
case 'AJAX':
modeSample = 'javascript'
codeSample = `// There should be an HTML form elsewhere on the page. See the "HTML" tab.
var form = document.querySelector('form')
var data = new FormData(form)
var req = new XMLHttpRequest()
req.open(form.method, form.action)
req.send(data)`
break
}
var integrationSnippet
if (this.state.activeTab === 'AJAX' && !form.captcha_disabled) {
integrationSnippet = (
<div className="integration-nocode CodeMirror cm-s-oceanic-next">
<p>Want to submit your form through AJAX?</p>
<p>
<Link to={`/forms/${form.hashid}/settings`}>Disable reCAPTCHA</Link>{' '}
for this form to make it possible!
</p>
</div>
)
} else {
integrationSnippet = (
<CodeMirror.UnControlled
value={codeSample}
options={{
theme: 'oceanic-next',
mode: modeSample,
viewportMargin: Infinity
}}
/>
)
}
return (
<>
<div className="col-1-1">
<div className="container">
<div>
<p>
Paste this code in your HTML, modifying it according to your
needs:
</p>
<div className="code-tabs">
{this.availableTabs.map(tabName => (
<div
key={tabName}
data-tab={tabName}
onClick={this.changeTab}
className={cs({active: this.state.activeTab === tabName})}
>
{tabName}
</div>
))}
</div>
{integrationSnippet}
</div>
</div>
</div>
</>
)
}
changeTab(e) {
e.preventDefault()
this.setState({activeTab: e.target.dataset.tab})
}
}

View File

@ -0,0 +1,219 @@
/** @format */
const toastr = window.toastr
const fetch = window.fetch
const React = require('react')
const SettingsSwitch = require('./SettingsSwitch')
module.exports = 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,
temporaryFormChanges: {}
}
}
render() {
let {form} = this.props
let tmp = this.state.temporaryFormChanges
return (
<>
<div className="container" id="settings">
<SettingsSwitch
title="Form Enabled"
fieldName="disabled"
description="You can disable this form to cause it to stop receiving new
submissions temporarily or permanently."
onChangeFn={() => this.update}
checkedFn={() =>
'disabled' in tmp
? !tmp.disabled
: !form.disabled
}
></SettingsSwitch>
<SettingsSwitch
title="reCAPTCHA"
fieldName="captcha_disabled"
description="reCAPTCHA provides vital spam protection, but you can turn it
off if you need."
onChangeFn={() => this.update}
checkedFn={() =>
'captcha_disabled' in tmp
? !tmp.captcha_disabled
: !form.captcha_disabled
}
></SettingsSwitch>
<SettingsSwitch
title="Email Notifications"
fieldName="disable_email"
description="You can disable the emails Formspree sends if you just want to
download the submissions from the dashboard."
onChangeFn={() => this.update}
checkedFn={() =>
'disable_email' in tmp
? !tmp.disable_email
: !form.disable_email
}
></SettingsSwitch>
<SettingsSwitch
title="Submission Archive"
fieldName="disable_storage"
description="You can disable the submission archive if you don't want
Formspree to store your submissions."
onChangeFn={() => this.update}
checkedFn={() =>
'disable_storage' in tmp
? !tmp.disable_storage
: !form.disable_storage
}
></SettingsSwitch>
<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>
</>
)
}
async update(e) {
let attr = e.currentTarget.name
let val = !e.currentTarget.checked
this.setState(state => {
state.temporaryFormChanges[attr] = val
return state
})
try {
let resp = await fetch(`/api-int/forms/${this.props.form.hashid}`, {
method: 'PATCH',
body: JSON.stringify({
[attr]: val
}),
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
})
let r = await resp.json()
if (!resp.ok || r.error) {
toastr.warning(
r.error
? `Failed to save settings: ${r.error}`
: 'Failed to save settings.'
)
return
}
toastr.success('Settings saved.')
this.props.onUpdate().then(() => {
this.setState({temporaryFormChanges: {}})
})
} catch (e) {
console.error(e)
toastr.error('Failed to update form. See the console for more details.')
this.setState({temporaryFormChanges: {}})
}
}
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 resp = await fetch(`/api-int/forms/${this.props.form.hashid}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: {
Accept: 'application/json'
}
})
let r = await resp.json()
if (resp.error || r.error) {
toastr.warning(
r.error
? `failed to delete form: ${r.error}`
: 'failed to delete form.'
)
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.')
}
}
}

View File

@ -0,0 +1,35 @@
/** @format */
const React = require('react') // eslint-disable-line no-unused-vars
module.exports = SettingsSwitch
function SettingsSwitch({title, fieldName, description, checkedFn, onChangeFn}) {
return (
<>
<div className="row">
<div className="col-1-1">
<h4>{title}</h4>
</div>
<div className="switch-row">
<label className="switch">
<input
type="checkbox"
onChange={onChangeFn()}
checked={checkedFn()}
name={fieldName}
/>
<span className="slider" />
</label>
</div>
</div>
<div className="row">
<div className="col-1-1">
<p className="description">
{description}
</p>
</div>
</div>
</>
)
}

View File

@ -0,0 +1,147 @@
/** @format */
const toastr = window.toastr
const fetch = window.fetch
const React = require('react')
module.exports = class FormSubmissions extends React.Component {
constructor(props) {
super(props)
this.deleteSubmission = this.deleteSubmission.bind(this)
this.showExportButtons = this.showExportButtons.bind(this)
this.state = {
exporting: false
}
}
render() {
let {form} = this.props
return (
<div className="col-1-1 submissions-col">
{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>
<div className="container">
<div className="row">
{this.state.exporting ? (
<div className="col-1-1 right">
<a
target="_blank"
className="button"
style={{marginRight: '5px'}}
href={`/forms/${form.hashid}.json`}
>
Export as JSON
</a>
<a
target="_blank"
className="button"
href={`/forms/${form.hashid}.csv`}
>
Export as CSV
</a>
</div>
) : (
<div className="col-1-1 right">
<button
onClick={this.showExportButtons}
href={`/forms/${form.hashid}.json`}
>
Export
</button>
</div>
)}
</div>
</div>
</>
) : (
<h3>No submissions archived yet.</h3>
)}
</div>
)
}
showExportButtons(e) {
e.preventDefault()
this.setState({exporting: true})
}
async deleteSubmission(e) {
e.preventDefault()
let subid = e.currentTarget.dataset.sub
try {
let resp = await fetch(
`/api-int/forms/${this.props.form.hashid}/submissions/${subid}`,
{
method: 'DELETE',
credentials: 'same-origin',
headers: {Accept: 'application/json'}
}
)
let r = await resp.json()
if (!resp.ok || r.error) {
toastr.warning(
r.error
? `Failed to delete submission: ${r.error}`
: `Failed to delete submission.`
)
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.'
)
}
}
}

View File

@ -0,0 +1,389 @@
/** @format */
const toastr = window.toastr
const fetch = window.fetch
const cs = require('class-set')
const qs = require('query-string')
const React = require('react')
const CodeMirror = require('react-codemirror2')
const Modal = require('react-modal')
require('codemirror/mode/xml/xml')
require('codemirror/mode/css/css')
const MODAL_REVERT = 'revert'
const MODAL_PREVIEW = 'preview'
const MODAL_SYNTAX = 'syntax'
module.exports = class FormSettings extends React.Component {
constructor(props) {
super(props)
this.changeFromName = this.changeFromName.bind(this)
this.changeSubject = this.changeSubject.bind(this)
this.changeStyle = this.changeStyle.bind(this)
this.changeBody = this.changeBody.bind(this)
this.preview = this.preview.bind(this)
this.showSyntax = this.showSyntax.bind(this)
this.closeModal = this.closeModal.bind(this)
this.changeTab = this.changeTab.bind(this)
this.attemptRevert = this.attemptRevert.bind(this)
this.revert = this.revert.bind(this)
this.deploy = this.deploy.bind(this)
this.defaultValues = {
from_name: 'Team Formspree',
subject: 'New submission from {{ _host }}',
style: `h1 {
color: black;
}
.content {
font-size: 15pt;
}`,
body: `<div class="content">
<h1>You've got a new submission from {{ _replyto }} on {{ _host }}</h1>
<table>
{{# _fields }}
<tr>
<th>{{ _name }}</th>
<td>{{ _value }}</td>
</tr>
{{/ _fields }}
</table>
</div>
<p>This submission was sent on {{ _time }}.</p>
<br>
<hr>`
}
this.availableTabs = ['HTML', 'CSS']
this.state = {
changes: {},
modal: null,
activeTab: 'HTML'
}
}
render() {
let {form} = this.props
let {from_name, subject, style, body} = {
...this.defaultValues,
...form.template,
...this.state.changes
}
var shownCode
switch (this.state.activeTab) {
case 'CSS':
shownCode = (
<CodeMirror.Controlled
value={style}
options={{
theme: 'oceanic-next',
mode: 'css',
viewportMargin: Infinity
}}
onBeforeChange={this.changeStyle}
/>
)
break
case 'HTML':
shownCode = (
<CodeMirror.Controlled
value={body}
options={{
theme: 'oceanic-next',
mode: 'xml',
viewportMargin: Infinity
}}
onBeforeChange={this.changeBody}
/>
)
}
return (
<>
<div id="whitelabel">
<div className="container">
<form>
<div className="col-1-6">
<label htmlFor="from_name">From</label>
</div>
<div className="col-5-6">
<input
id="from_name"
onChange={this.changeFromName}
value={from_name}
/>
</div>
<div className="col-1-6">
<label htmlFor="subject">Subject</label>
</div>
<div className="col-5-6">
<input
id="subject"
onChange={this.changeSubject}
value={subject}
/>
<div className="subtext">
Overrides <span className="code">_subject</span> field
</div>
</div>
</form>
</div>
<div className="container">
<div className="col-1-1 right">
<div className="syntax_ref">
<a href="#" onClick={this.showSyntax}>
syntax quick reference
</a>
</div>
</div>
<div className="col-1-1">
<div className="code-tabs">
{this.availableTabs.map(tabName => (
<div
key={tabName}
data-tab={tabName}
onClick={this.changeTab}
className={cs({active: this.state.activeTab === tabName})}
>
{tabName}
</div>
))}
</div>
{shownCode}
</div>
</div>
<div className="container">
<div className="col-1-3">
<button onClick={this.preview}>Preview</button>
</div>
<div className="col-1-3 right">
{Object.keys(this.state.changes).length > 0
? 'changes pending'
: '\u00A0'}
</div>
<div className="col-1-6 right">
<button
onClick={this.attemptRevert}
disabled={Object.keys(this.state.changes).length === 0}
>
Revert
</button>
</div>
<div className="col-1-6 right">
<button
onClick={this.deploy}
disabled={Object.keys(this.state.changes).length === 0}
>
Deploy
</button>
</div>
</div>
</div>
<Modal
contentLabel="Revert changes"
isOpen={this.state.modal === MODAL_REVERT}
onRequestClose={this.closeModal}
className="dummy"
overlayClassName="dummy"
>
<div>
<div className="container">
<h2>Are you sure?</h2>
<p>
Reverting will discard the changes you've made to your email
template.
</p>
</div>
<div className="container right">
<button onClick={this.closeModal}>Cancel</button>
<button onClick={this.revert}>Revert</button>
</div>
</div>
</Modal>
<Modal
contentLabel="Preview"
isOpen={this.state.modal === MODAL_PREVIEW}
onRequestClose={this.closeModal}
className="dummy"
overlayClassName="dummy"
>
<div id="whitelabel-preview-modal">
<iframe
className="preview"
src={
'/forms/whitelabel/preview?' +
qs.stringify({
from_name,
subject,
style,
body
})
}
/>
<div className="container right">
<button onClick={this.closeModal}>OK</button>
</div>
</div>
</Modal>
<Modal
contentLabel="Email Syntax"
isOpen={this.state.modal === MODAL_SYNTAX}
onRequestClose={this.closeModal}
className="dummy"
overlayClassName="dummy"
>
<div>
<div>
<h2>Email Syntax</h2>
<p>
the email body can contain simple HTML that's valid in an email.
No <span className="code">&lt;script&gt;</span> or{' '}
<span className="code">&lt;style&gt;</span> tags can be
included. For a list of recommended HTML tags see{' '}
<a href="" target="_blank">
the ContantContact guide to HTML in email
</a>
.
</p>
<p>
The following special variables are recognized by Formspree,
using the{' '}
<a
href="https://mustache.github.io/mustache.5.html"
target="_blank"
>
mustache
</a>{' '}
template language.
</p>
<pre>
{`
{{ _time }} The date and time of the submission.
{{ _host }} The URL of the form (without "https://").
{{ <fieldname> }} Any named input value in your form.
{{# _fields }} Starts a list of all fields.
{{ _name }} Within _fields, the current field name
{{ _value }} and field value.
{{/ _fields }} Closes the _fields block.
`.trim()}
</pre>
</div>
<div className="container right">
<button onClick={this.closeModal}>OK</button>
</div>
</div>
</Modal>
</>
)
}
changeTab(e) {
e.preventDefault()
this.setState({activeTab: e.target.dataset.tab})
}
changeFromName(e) {
let value = e.target.value
this.setState(state => {
state.changes.from_name = value
return state
})
}
changeSubject(e) {
let value = e.target.value
this.setState(state => {
state.changes.subject = value
return state
})
}
changeStyle(_, __, value) {
this.setState(state => {
state.changes.style = value
return state
})
}
changeBody(_, __, value) {
this.setState(state => {
state.changes.body = value
return state
})
}
closeModal() {
this.setState({modal: null})
}
async preview(e) {
e.preventDefault()
this.setState({modal: MODAL_PREVIEW})
}
attemptRevert(e) {
e.preventDefault()
this.setState({modal: MODAL_REVERT})
}
revert(e) {
e.preventDefault()
this.setState({changes: {}, modal: null})
}
showSyntax(e) {
e.preventDefault()
this.setState({modal: MODAL_SYNTAX})
}
async deploy(e) {
e.preventDefault()
try {
let resp = await fetch(
`/api-int/forms/${this.props.form.hashid}/whitelabel`,
{
method: 'PUT',
body: JSON.stringify({
...this.defaultValues,
...this.props.form.template,
...this.state.changes
}),
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
}
)
let r = await resp.json()
if (!resp.ok || r.error) {
toastr.warning(
r.error
? `Failed to save custom template: ${r.error}`
: 'Failed to save custom template.'
)
return
}
toastr.success('Custom template saved.')
this.props.onUpdate().then(() => {
this.setState({changes: {}})
})
} catch (e) {
console.error(e)
toastr.error(
'Failed to save custom template. See the console for more details.'
)
}
}
}

View File

@ -0,0 +1,136 @@
/** @format */
const toastr = window.toastr
const fetch = window.fetch
const React = require('react')
const {Route, Link, NavLink, Redirect} = require('react-router-dom')
const Portal = require('../../Portal')
const Integration = require('./Integration')
const Submissions = require('./Submissions')
const Settings = require('./Settings')
const Whitelabel = require('./Whitelabel')
const FormDescription = require('./FormDescription')
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 (
<>
<Portal to=".menu .item:nth-child(2)">
<Link to="/forms">Your forms</Link>
</Portal>
<Portal to="#header .center">
<h1>Form Details</h1>
{this.state.form &&
<FormDescription prefix="For " form={this.state.form} />
}
</Portal>
<Route
exact
path={`/forms/${hashid}`}
render={() => <Redirect to={`/forms/${hashid}/submissions`} />}
/>
{this.state.form && (
<>
<h4 className="tabs">
<NavLink
to={`/forms/${hashid}/integration`}
activeStyle={{color: 'inherit', cursor: 'normal'}}
>
Integration
</NavLink>
<NavLink
to={`/forms/${hashid}/submissions`}
activeStyle={{color: 'inherit', cursor: 'normal'}}
>
Submissions
</NavLink>
<NavLink
to={`/forms/${hashid}/settings`}
activeStyle={{color: 'inherit', cursor: 'normal'}}
>
Settings
</NavLink>
{this.state.form.features.whitelabel && (
<NavLink
to={`/forms/${hashid}/whitelabel`}
activeStyle={{color: 'inherit', cursor: 'normal'}}
>
Whitelabel
</NavLink>
)}
</h4>
<Route
path="/forms/:hashid/integration"
render={() => (
<Integration form={this.state.form} onUpdate={this.fetchForm} />
)}
/>
<Route
path="/forms/:hashid/submissions"
render={() => (
<Submissions form={this.state.form} onUpdate={this.fetchForm} />
)}
/>
<Route
path="/forms/:hashid/settings"
render={() => (
<Settings
form={this.state.form}
history={this.props.history}
onUpdate={this.fetchForm}
/>
)}
/>
<Route
path="/forms/:hashid/whitelabel"
render={() => (
<Whitelabel form={this.state.form} onUpdate={this.fetchForm} />
)}
/>
</>
)}
</>
)
}
async fetchForm() {
let hashid = this.props.match.params.hashid
try {
let resp = await fetch(`/api-int/forms/${hashid}`, {
credentials: 'same-origin',
headers: {Accept: 'application/json'}
})
let r = await resp.json()
if (!resp.ok || r.error) {
toastr.warning(
r.error ? `Error fetching form: ${r.error}` : 'Error fetching form.'
)
return
}
this.setState({form: r})
} catch (e) {
console.error(e)
toastr.error(`Failed to fetch form, see the console for more details.`)
}
}
}

View File

@ -2,10 +2,14 @@
const render = require('react-dom').render
const React = require('react') // eslint-disable-line no-unused-vars
const Modal = require('react-modal')
const Dashboard = require('./Dashboard')
if (document.querySelector('body.forms.dashboard')) {
let el = document.querySelector('.container.block')
Modal.setAppElement(el)
document.querySelector('.menu .item:nth-child(2)').innerHTML = ''
render(<Dashboard />, document.querySelector('.container.block'))
render(<Dashboard />, el)
}

View File

@ -43,6 +43,7 @@ def configure_routes(app):
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('/forms/whitelabel/preview', view_func=fv.custom_template_preview_render, methods=['GET'])
app.add_url_rule('/api-int/forms', view_func=fa.list, methods=['GET'])
app.add_url_rule('/api-int/forms', view_func=fa.create, methods=['POST'])
app.add_url_rule('/api-int/forms/<hashid>', view_func=fa.get, methods=['GET'])
@ -50,6 +51,7 @@ def configure_routes(app):
app.add_url_rule('/api-int/forms/<hashid>', view_func=fa.delete, methods=['DELETE'])
app.add_url_rule('/api-int/forms/sitewide-check', view_func=fa.sitewide_check, methods=['POST'])
app.add_url_rule('/api-int/forms/<hashid>/submissions/<submissionid>', view_func=fa.submission_delete, methods=['DELETE'])
app.add_url_rule('/api-int/forms/<hashid>/whitelabel', view_func=fa.custom_template_set, methods=['PUT'])
# Webhooks
app.add_url_rule('/webhooks/stripe', view_func=uv.stripe_webhook, methods=['POST'])

View File

@ -123,9 +123,10 @@
.form-description {
font-size: 20px;
margin-top: -1em;
}
.integration-tabs {
.code-tabs {
& > * {
display: inline-block;
cursor: pointer;
@ -190,6 +191,13 @@
overflow-x: auto;
}
a.button.export {
width: 11em;
text-align: center;
display: inline-block;
margin: 0 0 20px 20px;
}
table {
text-align: left;
width: 100%;
@ -375,14 +383,8 @@
top: -100%;
z-index: 11;
width: 770px;
-webkit-transform: translate(0, -500%);
-ms-transform: translate(0, -500%);
transform: translate(0, -500%);
-webkit-transition: -webkit-transform 0.3s ease-out;
-moz-transition: -moz-transform 0.3s ease-out;
-o-transition: -o-transform 0.3s ease-out;
transition: transform 0.3s ease-out;
transition: transform 0.5s ease-out;
padding: 0.3em 1.3em 1.3em 1.3em;
.x a {
@ -494,14 +496,74 @@
}
}
a.button.export {
width: 11em;
text-align: center;
display: inline-block;
margin: 0 0 20px 20px;
#whitelabel {
.container {
padding-bottom: 0;
}
#from_name::after {
content: ' submissions@formspree.io';
color: grey;
}
.syntax_ref {
position: relative;
top: 2em;
display: inline-block;
@media (max-width: 760px) {
text-align: left;
top: 0;
}
}
.right {
@media (max-width: 760px) {
text-align: left;
}
}
}
#whitelabel-preview-modal {
.preview {
padding: 12px;
margin: 12px;
border: 5px dotted #444;
width: 700px;
height: 600px;
}
}
.ReactModal__Overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.5);
display: flex;
justify-content: center;
.ReactModal__Content {
z-index: 11;
position: absolute;
top: 80px;
background: #fefefe;
border: #333333 solid 1px;
border-radius: 5px;
padding: 1.4em 1.4em 0.5em 1.4em;
max-width: 900px;
transform: translate(0, -500%);
transition: transform 0.6s ease-out;
}
&.ReactModal__Overlay--after-open .ReactModal__Content {
transform: translate(0, 0);
}
}
.CodeMirror {
z-index: 0;
padding: 10px;
height: auto;
font-size: 90%;

View File

@ -66,6 +66,7 @@ body#card {
border-bottom: 1px solid #ddd;
&#header {
border-color: $green;
padding: 4em 0;
}
}

View File

@ -26,7 +26,9 @@
}
.switch-row {
margin: auto;
// margin: auto;
// carefully placed to work within formspree grid
margin: 14px 0 0 -20px;
position: relative;
text-align: right;
.switch {

View File

@ -67,6 +67,11 @@ a {
text-transform: uppercase;
}
.subtext {
color: $gray;
font-size: small;
}
.h1-seo-hack {
font-size: $h1_font_size;
margin: $h1_margin;

View File

@ -1,5 +1,5 @@
import os
from premailer import transform
from premailer import Premailer
TEMPLATES_DIR = 'formspree/templates/email/pre_inline_style/'
@ -8,7 +8,8 @@ def generate_templates():
for filename in os.listdir(TEMPLATES_DIR):
if filename.endswith('.html'):
with open(os.path.join(TEMPLATES_DIR, filename), 'r') as html:
transformed_template = transform(html.read())
p = Premailer(html.read(), remove_classes=True)
transformed_template = p.transform()
# weird issue with jinja templates beforehand so we use this hack
# see https://github.com/peterbe/premailer/issues/72

View File

@ -64,7 +64,7 @@
<div class="menu">
{% if request.path != '/' %}<span class="item"><a href="/">Home</a></span>{% endif %}
<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>
<span class="item"><a href="{{ url_for('account') }}">{% if current_user.has_feature('dashboard') %}Your{% else %}Upgrade{% endif %} account</a></span>
{% else %}
<div class="greetings">
<h4 class="light">

View File

@ -43,7 +43,7 @@
<div class="card">
<h3>Plan</h3>
{% if current_user.upgraded %}
{% if current_user.has_feature('dashboard') %}
<p>You are a {{ config.SERVICE_NAME }} {{ config.UPGRADED_PLAN_NAME }} user.</p>
{% if sub.cancel_at_period_end %}
<p>You've cancelled your subscription and it is ending on {{ sub.current_period_end }}.</p>

View File

@ -11,7 +11,7 @@
<div class="card">
<h3>Plan</h3>
{% if current_user.upgraded %}
{% if current_user.has_feature('dashboard') %}
<p>You are a {{ config.SERVICE_NAME }} {{ config.UPGRADED_PLAN_NAME }} user.</p>
{% if sub.cancel_at_period_end %}
<p>You've cancelled your subscription and it is ending on {{ sub.current_period_end }}.</p>

View File

@ -18,7 +18,7 @@
<div class="row section grey">
<div class="container narrow block">
{% if current_user.upgraded %}
{% if current_user.has_feature('dashboard') %}
<div class="col-1-2">
<p>{{config.SERVICE_NAME}} is a tool <a href="https://github.com/formspree/formspree">managed on GitHub</a>. For fastest support check out our <a href="https://help.formspree.io">knowledge base</a> or use the form on the right.</p>
</div>

View File

@ -1,6 +1,7 @@
import hmac
import hashlib
from datetime import datetime
from flask import url_for, render_template, render_template_string, g
from formspree import settings
@ -8,13 +9,30 @@ from formspree.stuff import DB, TEMPLATES
from formspree.utils import send_email, IS_VALID_EMAIL
from .helpers import hash_pwd
class Plan(DB.Enum):
free = 'v1_free'
gold = 'v1_gold'
platinum = 'v1_platinum'
plan_features = {
'v1_free': set(),
'v1_gold': {'dashboard', 'unlimited'},
'v1_platinum': {'dashboard', 'unlimited', 'whitelabel'}
}
@classmethod
def has_feature(cls, plan, feature_id):
return feature_id in cls.plan_features[plan]
class User(DB.Model):
__tablename__ = 'users'
id = DB.Column(DB.Integer, primary_key=True)
email = DB.Column(DB.Text, unique=True, index=True)
password = DB.Column(DB.String(100))
upgraded = DB.Column(DB.Boolean)
plan = DB.Column(DB.Enum(*Plan.plan_features.keys(), name='plans'), nullable=False)
stripe_id = DB.Column(DB.String(50))
registered_on = DB.Column(DB.DateTime)
invoice_address = DB.Column(DB.Text)
@ -40,7 +58,7 @@ class User(DB.Model):
self.email = email
self.password = hash_pwd(password)
self.upgraded = False
self.plan = Plan.free
self.registered_on = datetime.utcnow()
@property
@ -55,6 +73,13 @@ class User(DB.Model):
def is_anonymous(self):
return False
@property
def features(self):
return Plan.plan_features[self.plan]
def has_feature(self, feature_id):
return Plan.has_feature(self.plan, feature_id)
def get_id(self):
return self.id

View File

@ -10,7 +10,7 @@ from sqlalchemy.exc import IntegrityError
from formspree import settings
from formspree.stuff import DB, TEMPLATES
from formspree.utils import send_email
from .models import User, Email
from .models import User, Email, Plan
from .helpers import check_password, hash_pwd, send_downgrade_email, \
send_downgrade_reason_email
@ -231,7 +231,7 @@ def upgrade():
"error")
return redirect(url_for('dashboard'))
current_user.upgraded = True
current_user.plan = Plan.gold
DB.session.add(current_user)
DB.session.commit()
flash(u"Congratulations! You are now a {SERVICE_NAME} "
@ -278,7 +278,8 @@ def downgrade():
if reason:
send_downgrade_reason_email.delay(current_user.email, reason)
sub = sub.delete(at_period_end=True)
sub.cancel_at_period_end = True
sub.save()
flash(u"You were unregistered from the {SERVICE_NAME} "
"{UPGRADED_PLAN_NAME} plan.".format(**settings.__dict__),
'success')
@ -310,7 +311,7 @@ def stripe_webhook():
customer = stripe.Customer.retrieve(customer_id)
if len(customer.subscriptions.data) == 0:
user = User.query.filter_by(stripe_id=customer_id).first()
user.upgraded = False
user.plan = Plan.free
DB.session.add(user)
DB.session.commit()
g.log.info('Downgraded user from webhook.', account=user.email)
@ -320,8 +321,10 @@ def stripe_webhook():
customer = stripe.Customer.retrieve(customer_id)
g.log.info('User payment failed', account=customer.email)
send_email(to=customer.email,
subject='[ACTION REQUIRED] Failed Payment for {} {}'.format(settings.SERVICE_NAME,
settings.UPGRADED_PLAN_NAME),
subject='[ACTION REQUIRED] Failed Payment for {} {}'.format(
settings.SERVICE_NAME,
settings.UPGRADED_PLAN_NAME
),
text=render_template('email/payment-failed.txt'),
html=render_template_string(TEMPLATES.get('payment-failed.html')),
sender=settings.DEFAULT_SENDER)
@ -433,6 +436,7 @@ def account():
sub.current_period_end = datetime.datetime.fromtimestamp(sub.current_period_end).strftime('%A, %B %d, %Y')
except stripe.error.StripeError:
return render_template('error.html', title='Unable to connect', text="We're unable to make a secure connection to verify your account details. Please try again in a little bit. If this problem persists, please contact <strong>%s</strong>" % settings.CONTACT_EMAIL)
return render_template('users/account.html', emails=emails, cards=cards, sub=sub)
@login_required

View File

@ -118,7 +118,8 @@ def next_url(referrer=None, next=None):
def send_email(to=None, subject=None, text=None, html=None,
sender=None, cc=None, reply_to=None, headers=None):
sender=None, cc=None, reply_to=None, headers=None,
from_name=None):
g.log = g.log.new(to=to, sender=sender)
if None in [to, subject, text, sender]:
@ -131,17 +132,20 @@ def send_email(to=None, subject=None, text=None, html=None,
'text': text,
'html': html}
# parse 'fromname' from 'sender' if it is
# parse 'from_name' from 'sender' if it is
# formatted like "Name <name@email.com>"
try:
bracket = sender.index('<')
data.update({
'from': sender[bracket+1:-1],
'fromname': sender[:bracket].strip()
'from_name': sender[:bracket].strip()
})
except ValueError:
data.update({'from': sender})
if from_name:
data.update({'from_name': from_name})
if headers:
data.update({'headers': json.dumps(headers)})

View File

@ -0,0 +1,54 @@
"""email templates and plan enum
Revision ID: 7446b8bbc888
Revises: a156683c29f2
Create Date: 2018-09-19 22:38:57.679264
"""
# revision identifiers, used by Alembic.
revision = '7446b8bbc888'
down_revision = 'a156683c29f2'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.dialects.postgresql import ENUM
plans = ENUM('v1_free', 'v1_gold', 'v1_platinum', name='plans', create_type=False)
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('email_templates',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('form_id', sa.Integer(), nullable=False),
sa.Column('subject', sa.Text(), nullable=False),
sa.Column('from_name', sa.Text(), nullable=False),
sa.Column('style', sa.Text(), nullable=False),
sa.Column('body', sa.Text(), nullable=False),
sa.ForeignKeyConstraint(['form_id'], ['forms.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('form_id')
)
op.alter_column('submissions', 'form_id',
existing_type=sa.INTEGER(),
nullable=False)
plans.create(op.get_bind(), checkfirst=True)
op.add_column('users', sa.Column('plan', plans, nullable=True))
op.execute("UPDATE users SET plan = 'v1_gold' WHERE upgraded")
op.execute("UPDATE users SET plan = 'v1_free' WHERE NOT upgraded")
op.drop_column('users', 'upgraded')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('upgraded', sa.BOOLEAN(), autoincrement=False, nullable=True))
op.execute("UPDATE users SET upgraded = true WHERE plan != 'v1_free'")
op.execute("UPDATE users SET upgraded = false WHERE plan = 'v1_free'")
op.drop_column('users', 'plan')
op.alter_column('submissions', 'form_id',
existing_type=sa.INTEGER(),
nullable=True)
op.drop_table('email_templates')
# ### end Alembic commands ###

View File

@ -3,9 +3,11 @@
"class-set": "^0.0.4",
"codemirror": "^5.39.2",
"is-valid-email": "0.0.2",
"query-string": "^6.1.0",
"react": "^16.4.2",
"react-codemirror2": "^5.1.0",
"react-dom": "^16.4.2",
"react-modal": "^3.5.1",
"react-router-dom": "^4.3.1",
"url": "^0.11.0",
"valid-url": "^1.0.9"

View File

@ -3,7 +3,7 @@ import json
from formspree import settings
from formspree.stuff import DB
from formspree.forms.helpers import HASH
from formspree.users.models import User
from formspree.users.models import User, Plan
from formspree.forms.models import Form, Submission
def test_automatically_created_forms(client, msend):
@ -147,7 +147,7 @@ def test_automatically_created_forms(client, msend):
assert newest.data['name'] == 'husserl'
assert last.data['name'] == 'schelling'
def test_upgraded_user_access(client, msend):
def test_gold_user_access(client, msend):
# register user
r = client.post('/register',
data={'email': 'colorado@springs.com',
@ -156,7 +156,7 @@ def test_upgraded_user_access(client, msend):
# upgrade user manually
user = User.query.filter_by(email='colorado@springs.com').first()
user.upgraded = True
user.plan = Plan.gold
DB.session.add(user)
DB.session.commit()
@ -209,7 +209,7 @@ def test_upgraded_user_access(client, msend):
assert '"hi in my name is bruce!"', lines[1]
# test submissions endpoint with the user downgraded
user.upgraded = False
user.plan = Plan.free
DB.session.add(user)
DB.session.commit()
r = client.get("/api-int/forms/" + form_endpoint)

View File

@ -2,7 +2,7 @@ import json
from formspree import settings
from formspree.stuff import DB
from formspree.users.models import User, Email
from formspree.users.models import User, Email, Plan
from formspree.forms.models import Form
from .conftest import parse_confirmation_link_sent
@ -61,7 +61,7 @@ def test_user_gets_previous_forms_assigned_to_him(client, msend):
link, qs = parse_confirmation_link_sent(msend.call_args[1]['text'])
client.get(link, query_string=qs)
# confirm that the user has no access to the form since he is not upgraded
# confirm that the user has no access to the form since he is not gold
r = client.get(
"/api-int/forms",
headers={"Accept": "application/json", "Referer": settings.SERVICE_URL},
@ -71,7 +71,7 @@ def test_user_gets_previous_forms_assigned_to_him(client, msend):
# upgrade user
user = User.query.filter_by(email=u'márkö@example.com').first()
user.upgraded = True
user.plan = Plan.gold
DB.session.add(user)
DB.session.commit()

View File

@ -3,7 +3,7 @@ import json
from formspree import settings
from formspree.stuff import DB
from formspree.forms.helpers import HASH
from formspree.users.models import User, Email
from formspree.users.models import User, Email, Plan
from formspree.forms.models import Form, Submission
def test_form_creation(client, msend):
@ -27,7 +27,7 @@ def test_form_creation(client, msend):
# upgrade user manually
user = User.query.filter_by(email='colorado@springs.com').first()
user.upgraded = True
user.plan = Plan.gold
DB.session.add(user)
DB.session.commit()
@ -67,7 +67,7 @@ def test_form_creation(client, msend):
# Make sure that it marks the first form as AJAX
assert Form.query.first().uses_ajax
# send 5 forms (monthly limits should not apply to the upgraded user)
# send 5 forms (monthly limits should not apply to the gold user)
assert settings.MONTHLY_SUBMISSIONS_LIMIT == 2
for i in range(5):
r = client.post(
@ -98,7 +98,7 @@ def test_form_creation_with_a_registered_email(client, msend):
)
# upgrade user manually
user = User.query.filter_by(email="user@testsite.com").first()
user.upgraded = True
user.plan = Plan.gold
DB.session.add(user)
DB.session.commit()
@ -180,7 +180,7 @@ def test_sitewide_forms(client, msend, mocker):
)
# upgrade user manually
user = User.query.filter_by(email="user@testsite.com").first()
user.upgraded = True
user.plan = Plan.gold
DB.session.add(user)
DB.session.commit()
@ -330,7 +330,7 @@ def test_form_settings(client, msend):
# register and upgrade user
client.post("/register", data={"email": "texas@springs.com", "password": "water"})
user = User.query.filter_by(email="texas@springs.com").first()
user.upgraded = True
user.plan = Plan.gold
DB.session.add(user)
DB.session.commit()

View File

@ -1,7 +1,7 @@
from formspree import settings
from formspree.stuff import DB
from formspree.forms.models import Form
from formspree.users.models import User, Email
from formspree.users.models import User, Email, Plan
http_headers = {
'Referer': 'testwebsite.com'
@ -233,13 +233,13 @@ def test_monthly_limits(client, msend):
assert f.counter == 3
assert f.get_monthly_counter() == 3 # the counters mark 4
# the user pays and becomes upgraded
# the user pays and becomes gold
r = client.post('/register',
data={'email': 'luke@testwebsite.com',
'password': 'banana'}
)
user = User.query.filter_by(email='luke@testwebsite.com').first()
user.upgraded = True
user.plan = Plan.gold
user.emails = [Email(address='luke@testwebsite.com')]
DB.session.add(user)
DB.session.commit()

View File

@ -5,7 +5,7 @@ import stripe
from formspree import settings
from formspree.stuff import DB
from formspree.forms.helpers import HASH
from formspree.users.models import User, Email
from formspree.users.models import User, Email, Plan
from formspree.forms.models import Form, Submission
from .conftest import parse_confirmation_link_sent
@ -123,7 +123,7 @@ def test_form_creation(client, msend):
# upgrade user manually
user = User.query.filter_by(email='colorado@springs.com').first()
user.upgraded = True
user.plan = Plan.gold
DB.session.add(user)
DB.session.commit()
@ -156,7 +156,7 @@ def test_form_creation(client, msend):
client.get('/confirm/%s:%s' % (HASH(form.email, str(form.id)), form.hashid))
assert Form.query.first().confirmed
# send 5 forms (monthly limits should not apply to the upgraded user)
# send 5 forms (monthly limits should not apply to the gold user)
assert settings.MONTHLY_SUBMISSIONS_LIMIT == 2
for i in range(5):
r = client.post('/' + form_endpoint,
@ -192,7 +192,7 @@ def test_form_toggle(client, msend):
# upgrade user
user = User.query.filter_by(email='hello@world.com').first()
user.upgraded = True
user.plan = Plan.gold
DB.session.add(user)
DB.session.commit()
@ -281,7 +281,7 @@ def test_form_and_submission_deletion(client, msend):
# upgrade user
user = User.query.filter_by(email='hello@world.com').first()
user.upgraded = True
user.plan = Plan.gold
DB.session.add(user)
DB.session.commit()
@ -399,7 +399,7 @@ def test_user_upgrade_and_downgrade(client, msend, mocker):
msend.reset_mock()
user = User.query.filter_by(email='maria@example.com').first()
assert user.upgraded == False
assert user.plan == Plan.free
# subscribe with card through stripe
token = stripe.Token.create(card={
@ -414,7 +414,7 @@ def test_user_upgrade_and_downgrade(client, msend, mocker):
})
user = User.query.filter_by(email='maria@example.com').first()
assert user.upgraded == True
assert user.plan == Plan.gold
# downgrade back to the free plan
r = client.post('/account/downgrade', follow_redirects=True)
@ -424,7 +424,7 @@ def test_user_upgrade_and_downgrade(client, msend, mocker):
assert "You've cancelled your subscription and it is ending on" in r.data.decode('utf-8')
user = User.query.filter_by(email='maria@example.com').first()
assert user.upgraded == True
assert user.plan == Plan.gold
customer = stripe.Customer.retrieve(user.stripe_id)
assert customer.subscriptions.data[0].cancel_at_period_end == True
@ -445,7 +445,7 @@ def test_user_upgrade_and_downgrade(client, msend, mocker):
}), headers={'Content-type': 'application/json'})
user = User.query.filter_by(email='maria@example.com').first()
assert user.upgraded == False
assert user.plan == Plan.free
assert m_senddowngraded.called
# delete the stripe customer
@ -465,7 +465,7 @@ def test_user_card_management(client, msend):
assert r.status_code == 302
user = User.query.filter_by(email='maria@example.com').first()
assert user.upgraded == False
assert user.plan == Plan.free
# subscribe with card through stripe
token = stripe.Token.create(card={
@ -479,7 +479,7 @@ def test_user_card_management(client, msend):
})
user = User.query.filter_by(email='maria@example.com').first()
assert user.upgraded == True
assert user.plan == Plan.gold
# add another card
token = stripe.Token.create(card={