Merge branch 'restructure'
This commit is contained in:
commit
445145f9b9
17 changed files with 729 additions and 375 deletions
|
@ -1,20 +1,13 @@
|
||||||
import json
|
PLANS = [
|
||||||
import itertools
|
# Deprecated Plans
|
||||||
|
|
||||||
USER_PLANS = [
|
|
||||||
{
|
|
||||||
'title': 'Open Source',
|
|
||||||
'price': 0,
|
|
||||||
'privateRepos': 0,
|
|
||||||
'stripeId': 'free',
|
|
||||||
'audience': 'Share with the world',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
'title': 'Micro',
|
'title': 'Micro',
|
||||||
'price': 700,
|
'price': 700,
|
||||||
'privateRepos': 5,
|
'privateRepos': 5,
|
||||||
'stripeId': 'micro',
|
'stripeId': 'micro',
|
||||||
'audience': 'For smaller teams',
|
'audience': 'For smaller teams',
|
||||||
|
'bus_features': False,
|
||||||
|
'deprecated': True,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'title': 'Basic',
|
'title': 'Basic',
|
||||||
|
@ -22,6 +15,8 @@ USER_PLANS = [
|
||||||
'privateRepos': 10,
|
'privateRepos': 10,
|
||||||
'stripeId': 'small',
|
'stripeId': 'small',
|
||||||
'audience': 'For your basic team',
|
'audience': 'For your basic team',
|
||||||
|
'bus_features': False,
|
||||||
|
'deprecated': True,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'title': 'Medium',
|
'title': 'Medium',
|
||||||
|
@ -29,6 +24,8 @@ USER_PLANS = [
|
||||||
'privateRepos': 20,
|
'privateRepos': 20,
|
||||||
'stripeId': 'medium',
|
'stripeId': 'medium',
|
||||||
'audience': 'For medium teams',
|
'audience': 'For medium teams',
|
||||||
|
'bus_features': False,
|
||||||
|
'deprecated': True,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'title': 'Large',
|
'title': 'Large',
|
||||||
|
@ -36,16 +33,28 @@ USER_PLANS = [
|
||||||
'privateRepos': 50,
|
'privateRepos': 50,
|
||||||
'stripeId': 'large',
|
'stripeId': 'large',
|
||||||
'audience': 'For larger teams',
|
'audience': 'For larger teams',
|
||||||
|
'bus_features': False,
|
||||||
|
'deprecated': True,
|
||||||
},
|
},
|
||||||
]
|
|
||||||
|
|
||||||
BUSINESS_PLANS = [
|
# Active plans
|
||||||
{
|
{
|
||||||
'title': 'Open Source',
|
'title': 'Open Source',
|
||||||
'price': 0,
|
'price': 0,
|
||||||
'privateRepos': 0,
|
'privateRepos': 0,
|
||||||
'stripeId': 'bus-free',
|
'stripeId': 'free',
|
||||||
'audience': 'Committment to FOSS',
|
'audience': 'Committment to FOSS',
|
||||||
|
'bus_features': False,
|
||||||
|
'deprecated': False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Personal',
|
||||||
|
'price': 1200,
|
||||||
|
'privateRepos': 5,
|
||||||
|
'stripeId': 'personal',
|
||||||
|
'audience': 'Individuals',
|
||||||
|
'bus_features': False,
|
||||||
|
'deprecated': False,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'title': 'Skiff',
|
'title': 'Skiff',
|
||||||
|
@ -53,6 +62,8 @@ BUSINESS_PLANS = [
|
||||||
'privateRepos': 10,
|
'privateRepos': 10,
|
||||||
'stripeId': 'bus-micro',
|
'stripeId': 'bus-micro',
|
||||||
'audience': 'For startups',
|
'audience': 'For startups',
|
||||||
|
'bus_features': True,
|
||||||
|
'deprecated': False,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'title': 'Yacht',
|
'title': 'Yacht',
|
||||||
|
@ -60,6 +71,8 @@ BUSINESS_PLANS = [
|
||||||
'privateRepos': 20,
|
'privateRepos': 20,
|
||||||
'stripeId': 'bus-small',
|
'stripeId': 'bus-small',
|
||||||
'audience': 'For small businesses',
|
'audience': 'For small businesses',
|
||||||
|
'bus_features': True,
|
||||||
|
'deprecated': False,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'title': 'Freighter',
|
'title': 'Freighter',
|
||||||
|
@ -67,6 +80,8 @@ BUSINESS_PLANS = [
|
||||||
'privateRepos': 50,
|
'privateRepos': 50,
|
||||||
'stripeId': 'bus-medium',
|
'stripeId': 'bus-medium',
|
||||||
'audience': 'For normal businesses',
|
'audience': 'For normal businesses',
|
||||||
|
'bus_features': True,
|
||||||
|
'deprecated': False,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'title': 'Tanker',
|
'title': 'Tanker',
|
||||||
|
@ -74,14 +89,16 @@ BUSINESS_PLANS = [
|
||||||
'privateRepos': 125,
|
'privateRepos': 125,
|
||||||
'stripeId': 'bus-large',
|
'stripeId': 'bus-large',
|
||||||
'audience': 'For large businesses',
|
'audience': 'For large businesses',
|
||||||
|
'bus_features': True,
|
||||||
|
'deprecated': False,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_plan(id):
|
def get_plan(plan_id):
|
||||||
""" Returns the plan with the given ID or None if none. """
|
""" Returns the plan with the given ID or None if none. """
|
||||||
for plan in itertools.chain(USER_PLANS, BUSINESS_PLANS):
|
for plan in PLANS:
|
||||||
if plan['stripeId'] == id:
|
if plan['stripeId'] == plan_id:
|
||||||
return plan
|
return plan
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
185
endpoints/api.py
185
endpoints/api.py
|
@ -12,7 +12,7 @@ from collections import defaultdict
|
||||||
|
|
||||||
from data import model
|
from data import model
|
||||||
from data.queue import dockerfile_build_queue
|
from data.queue import dockerfile_build_queue
|
||||||
from data.plans import USER_PLANS, BUSINESS_PLANS, get_plan
|
from data.plans import PLANS, get_plan
|
||||||
from app import app
|
from app import app
|
||||||
from util.email import send_confirmation_email, send_recovery_email
|
from util.email import send_confirmation_email, send_recovery_email
|
||||||
from util.names import parse_repository_name, format_robot_username
|
from util.names import parse_repository_name, format_robot_username
|
||||||
|
@ -30,6 +30,7 @@ from endpoints.web import common_login
|
||||||
from util.cache import cache_control
|
from util.cache import cache_control
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
store = app.config['STORAGE']
|
store = app.config['STORAGE']
|
||||||
user_files = app.config['USERFILES']
|
user_files = app.config['USERFILES']
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -37,8 +38,9 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def log_action(kind, user_or_orgname, metadata={}, repo=None):
|
def log_action(kind, user_or_orgname, metadata={}, repo=None):
|
||||||
performer = current_user.db_user()
|
performer = current_user.db_user()
|
||||||
model.log_action(kind, user_or_orgname, performer=performer, ip=request.remote_addr,
|
model.log_action(kind, user_or_orgname, performer=performer,
|
||||||
metadata=metadata, repository=repo)
|
ip=request.remote_addr, metadata=metadata, repository=repo)
|
||||||
|
|
||||||
|
|
||||||
def api_login_required(f):
|
def api_login_required(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
|
@ -64,7 +66,7 @@ def handle_dme(ex):
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(KeyError)
|
@app.errorhandler(KeyError)
|
||||||
def handle_dme(ex):
|
def handle_dme_key_error(ex):
|
||||||
return make_response(ex.message, 400)
|
return make_response(ex.message, 400)
|
||||||
|
|
||||||
|
|
||||||
|
@ -76,8 +78,7 @@ def welcome():
|
||||||
@app.route('/api/plans/')
|
@app.route('/api/plans/')
|
||||||
def plans_list():
|
def plans_list():
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'user': USER_PLANS,
|
'plans': PLANS,
|
||||||
'business': BUSINESS_PLANS,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@ -164,7 +165,7 @@ def convert_user_to_organization():
|
||||||
|
|
||||||
# Subscribe the organization to the new plan.
|
# Subscribe the organization to the new plan.
|
||||||
plan = convert_data['plan']
|
plan = convert_data['plan']
|
||||||
subscribe(user, plan, None, BUSINESS_PLANS)
|
subscribe(user, plan, None, True) # Require business plans
|
||||||
|
|
||||||
# Convert the user to an organization.
|
# Convert the user to an organization.
|
||||||
model.convert_user_to_organization(user, model.get_user(admin_username))
|
model.convert_user_to_organization(user, model.get_user(admin_username))
|
||||||
|
@ -172,7 +173,6 @@ def convert_user_to_organization():
|
||||||
|
|
||||||
# And finally login with the admin credentials.
|
# And finally login with the admin credentials.
|
||||||
return conduct_signin(admin_username, admin_password)
|
return conduct_signin(admin_username, admin_password)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/user/', methods=['PUT'])
|
@app.route('/api/user/', methods=['PUT'])
|
||||||
|
@ -427,7 +427,7 @@ def change_organization_details(orgname):
|
||||||
except model.InvalidOrganizationException:
|
except model.InvalidOrganizationException:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
org_data = request.get_json();
|
org_data = request.get_json()
|
||||||
if 'invoice_email' in org_data:
|
if 'invoice_email' in org_data:
|
||||||
logger.debug('Changing invoice_email for organization: %s', org.username)
|
logger.debug('Changing invoice_email for organization: %s', org.username)
|
||||||
model.change_invoice_email(org, org_data['invoice_email'])
|
model.change_invoice_email(org, org_data['invoice_email'])
|
||||||
|
@ -477,7 +477,7 @@ def get_organization_member(orgname, membername):
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
member_dict = None
|
member_dict = None
|
||||||
member_teams = model.get_organization_members_with_teams(org, membername = membername)
|
member_teams = model.get_organization_members_with_teams(org, membername=membername)
|
||||||
for member in member_teams:
|
for member in member_teams:
|
||||||
if not member_dict:
|
if not member_dict:
|
||||||
member_dict = {'username': member.user.username,
|
member_dict = {'username': member.user.username,
|
||||||
|
@ -551,17 +551,20 @@ def update_organization_team(orgname, teamname):
|
||||||
log_action('org_create_team', orgname, {'team': teamname})
|
log_action('org_create_team', orgname, {'team': teamname})
|
||||||
|
|
||||||
if is_existing:
|
if is_existing:
|
||||||
if 'description' in details and team.description != details['description']:
|
if ('description' in details and
|
||||||
|
team.description != details['description']):
|
||||||
team.description = details['description']
|
team.description = details['description']
|
||||||
team.save()
|
team.save()
|
||||||
log_action('org_set_team_description', orgname, {'team': teamname, 'description': team.description})
|
log_action('org_set_team_description', orgname,
|
||||||
|
{'team': teamname, 'description': team.description})
|
||||||
|
|
||||||
if 'role' in details:
|
if 'role' in details:
|
||||||
role = model.get_team_org_role(team).name
|
role = model.get_team_org_role(team).name
|
||||||
if role != details['role']:
|
if role != details['role']:
|
||||||
team = model.set_team_org_permission(team, details['role'],
|
team = model.set_team_org_permission(team, details['role'],
|
||||||
current_user.db_user().username)
|
current_user.db_user().username)
|
||||||
log_action('org_set_team_role', orgname, {'team': teamname, 'role': details['role']})
|
log_action('org_set_team_role', orgname,
|
||||||
|
{'team': teamname, 'role': details['role']})
|
||||||
|
|
||||||
resp = jsonify(team_view(orgname, team))
|
resp = jsonify(team_view(orgname, team))
|
||||||
if not is_existing:
|
if not is_existing:
|
||||||
|
@ -629,7 +632,8 @@ def update_organization_team_member(orgname, teamname, membername):
|
||||||
|
|
||||||
# Add the user to the team.
|
# Add the user to the team.
|
||||||
model.add_user_to_team(user, team)
|
model.add_user_to_team(user, team)
|
||||||
log_action('org_add_team_member', orgname, {'member': membername, 'team': teamname})
|
log_action('org_add_team_member', orgname,
|
||||||
|
{'member': membername, 'team': teamname})
|
||||||
return jsonify(member_view(user))
|
return jsonify(member_view(user))
|
||||||
|
|
||||||
abort(403)
|
abort(403)
|
||||||
|
@ -644,7 +648,8 @@ def delete_organization_team_member(orgname, teamname, membername):
|
||||||
# Remote the user from the team.
|
# Remote the user from the team.
|
||||||
invoking_user = current_user.db_user().username
|
invoking_user = current_user.db_user().username
|
||||||
model.remove_user_from_team(orgname, teamname, membername, invoking_user)
|
model.remove_user_from_team(orgname, teamname, membername, invoking_user)
|
||||||
log_action('org_remove_team_member', orgname, {'member': membername, 'team': teamname})
|
log_action('org_remove_team_member', orgname,
|
||||||
|
{'member': membername, 'team': teamname})
|
||||||
return make_response('Deleted', 204)
|
return make_response('Deleted', 204)
|
||||||
|
|
||||||
abort(403)
|
abort(403)
|
||||||
|
@ -673,7 +678,9 @@ def create_repo_api():
|
||||||
repo.description = req['description']
|
repo.description = req['description']
|
||||||
repo.save()
|
repo.save()
|
||||||
|
|
||||||
log_action('create_repo', namespace_name, {'repo': repository_name, 'namespace': namespace_name}, repo=repo)
|
log_action('create_repo', namespace_name,
|
||||||
|
{'repo': repository_name, 'namespace': namespace_name},
|
||||||
|
repo=repo)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'namespace': namespace_name,
|
'namespace': namespace_name,
|
||||||
'name': repository_name
|
'name': repository_name
|
||||||
|
@ -758,7 +765,8 @@ def update_repo_api(namespace, repository):
|
||||||
repo.description = values['description']
|
repo.description = values['description']
|
||||||
repo.save()
|
repo.save()
|
||||||
|
|
||||||
log_action('set_repo_description', namespace, {'repo': repository, 'description': values['description']},
|
log_action('set_repo_description', namespace,
|
||||||
|
{'repo': repository, 'description': values['description']},
|
||||||
repo=repo)
|
repo=repo)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True
|
'success': True
|
||||||
|
@ -778,7 +786,8 @@ def change_repo_visibility_api(namespace, repository):
|
||||||
if repo:
|
if repo:
|
||||||
values = request.get_json()
|
values = request.get_json()
|
||||||
model.set_repository_visibility(repo, values['visibility'])
|
model.set_repository_visibility(repo, values['visibility'])
|
||||||
log_action('change_repo_visibility', namespace, {'repo': repository, 'visibility': values['visibility']},
|
log_action('change_repo_visibility', namespace,
|
||||||
|
{'repo': repository, 'visibility': values['visibility']},
|
||||||
repo=repo)
|
repo=repo)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True
|
'success': True
|
||||||
|
@ -795,7 +804,8 @@ def delete_repository(namespace, repository):
|
||||||
if permission.can():
|
if permission.can():
|
||||||
model.purge_repository(namespace, repository)
|
model.purge_repository(namespace, repository)
|
||||||
registry.delete_repository_storage(namespace, repository)
|
registry.delete_repository_storage(namespace, repository)
|
||||||
log_action('delete_repo', namespace, {'repo': repository, 'namespace': namespace})
|
log_action('delete_repo', namespace,
|
||||||
|
{'repo': repository, 'namespace': namespace})
|
||||||
return make_response('Deleted', 204)
|
return make_response('Deleted', 204)
|
||||||
|
|
||||||
abort(403)
|
abort(403)
|
||||||
|
@ -912,7 +922,8 @@ def request_repo_build(namespace, repository):
|
||||||
dockerfile_build_queue.put(json.dumps({'build_id': build_request.id}))
|
dockerfile_build_queue.put(json.dumps({'build_id': build_request.id}))
|
||||||
|
|
||||||
log_action('build_dockerfile', namespace,
|
log_action('build_dockerfile', namespace,
|
||||||
{'repo': repository, 'namespace': namespace, 'fileid': dockerfile_id}, repo=repo)
|
{'repo': repository, 'namespace': namespace,
|
||||||
|
'fileid': dockerfile_id}, repo=repo)
|
||||||
|
|
||||||
resp = jsonify({
|
resp = jsonify({
|
||||||
'started': True
|
'started': True
|
||||||
|
@ -943,7 +954,8 @@ def create_webhook(namespace, repository):
|
||||||
resp.headers['Location'] = url_for('get_webhook', repository=repo_string,
|
resp.headers['Location'] = url_for('get_webhook', repository=repo_string,
|
||||||
public_id=webhook.public_id)
|
public_id=webhook.public_id)
|
||||||
log_action('add_repo_webhook', namespace,
|
log_action('add_repo_webhook', namespace,
|
||||||
{'repo': repository, 'webhook_id': webhook.public_id}, repo=repo)
|
{'repo': repository, 'webhook_id': webhook.public_id},
|
||||||
|
repo=repo)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
abort(403) # Permissions denied
|
abort(403) # Permissions denied
|
||||||
|
@ -1143,7 +1155,8 @@ def list_repo_user_permissions(namespace, repository):
|
||||||
current_func = role_view_func
|
current_func = role_view_func
|
||||||
|
|
||||||
def wrapped_role_org_view(repo_perm):
|
def wrapped_role_org_view(repo_perm):
|
||||||
return wrap_role_view_org(current_func(repo_perm), repo_perm.user, org_members)
|
return wrap_role_view_org(current_func(repo_perm), repo_perm.user,
|
||||||
|
org_members)
|
||||||
|
|
||||||
role_view_func = wrapped_role_org_view
|
role_view_func = wrapped_role_org_view
|
||||||
|
|
||||||
|
@ -1228,7 +1241,8 @@ def change_user_permissions(namespace, repository, username):
|
||||||
return error_resp
|
return error_resp
|
||||||
|
|
||||||
log_action('change_repo_permission', namespace,
|
log_action('change_repo_permission', namespace,
|
||||||
{'username': username, 'repo': repository, 'role': new_permission['role']},
|
{'username': username, 'repo': repository,
|
||||||
|
'role': new_permission['role']},
|
||||||
repo=model.get_repository(namespace, repository))
|
repo=model.get_repository(namespace, repository))
|
||||||
|
|
||||||
resp = jsonify(perm_view)
|
resp = jsonify(perm_view)
|
||||||
|
@ -1255,7 +1269,8 @@ def change_team_permissions(namespace, repository, teamname):
|
||||||
new_permission['role'])
|
new_permission['role'])
|
||||||
|
|
||||||
log_action('change_repo_permission', namespace,
|
log_action('change_repo_permission', namespace,
|
||||||
{'team': teamname, 'repo': repository, 'role': new_permission['role']},
|
{'team': teamname, 'repo': repository,
|
||||||
|
'role': new_permission['role']},
|
||||||
repo=model.get_repository(namespace, repository))
|
repo=model.get_repository(namespace, repository))
|
||||||
|
|
||||||
resp = jsonify(role_view(perm))
|
resp = jsonify(role_view(perm))
|
||||||
|
@ -1282,7 +1297,8 @@ def delete_user_permissions(namespace, repository, username):
|
||||||
error_resp.status_code = 400
|
error_resp.status_code = 400
|
||||||
return error_resp
|
return error_resp
|
||||||
|
|
||||||
log_action('delete_repo_permission', namespace, {'username': username, 'repo': repository},
|
log_action('delete_repo_permission', namespace,
|
||||||
|
{'username': username, 'repo': repository},
|
||||||
repo=model.get_repository(namespace, repository))
|
repo=model.get_repository(namespace, repository))
|
||||||
|
|
||||||
return make_response('Deleted', 204)
|
return make_response('Deleted', 204)
|
||||||
|
@ -1299,7 +1315,8 @@ def delete_team_permissions(namespace, repository, teamname):
|
||||||
if permission.can():
|
if permission.can():
|
||||||
model.delete_team_permission(teamname, namespace, repository)
|
model.delete_team_permission(teamname, namespace, repository)
|
||||||
|
|
||||||
log_action('delete_repo_permission', namespace, {'team': teamname, 'repo': repository},
|
log_action('delete_repo_permission', namespace,
|
||||||
|
{'team': teamname, 'repo': repository},
|
||||||
repo=model.get_repository(namespace, repository))
|
repo=model.get_repository(namespace, repository))
|
||||||
|
|
||||||
return make_response('Deleted', 204)
|
return make_response('Deleted', 204)
|
||||||
|
@ -1353,7 +1370,8 @@ def create_token(namespace, repository):
|
||||||
token = model.create_delegate_token(namespace, repository,
|
token = model.create_delegate_token(namespace, repository,
|
||||||
token_params['friendlyName'])
|
token_params['friendlyName'])
|
||||||
|
|
||||||
log_action('add_repo_accesstoken', namespace, {'repo': repository, 'token': token_params['friendlyName']},
|
log_action('add_repo_accesstoken', namespace,
|
||||||
|
{'repo': repository, 'token': token_params['friendlyName']},
|
||||||
repo = model.get_repository(namespace, repository))
|
repo = model.get_repository(namespace, repository))
|
||||||
|
|
||||||
resp = jsonify(token_view(token))
|
resp = jsonify(token_view(token))
|
||||||
|
@ -1378,7 +1396,8 @@ def change_token(namespace, repository, code):
|
||||||
new_permission['role'])
|
new_permission['role'])
|
||||||
|
|
||||||
log_action('change_repo_permission', namespace,
|
log_action('change_repo_permission', namespace,
|
||||||
{'repo': repository, 'token': token.friendly_name, 'code': code, 'role': new_permission['role']},
|
{'repo': repository, 'token': token.friendly_name, 'code': code,
|
||||||
|
'role': new_permission['role']},
|
||||||
repo = model.get_repository(namespace, repository))
|
repo = model.get_repository(namespace, repository))
|
||||||
|
|
||||||
resp = jsonify(token_view(token))
|
resp = jsonify(token_view(token))
|
||||||
|
@ -1397,7 +1416,8 @@ def delete_token(namespace, repository, code):
|
||||||
token = model.delete_delegate_token(namespace, repository, code)
|
token = model.delete_delegate_token(namespace, repository, code)
|
||||||
|
|
||||||
log_action('delete_repo_accesstoken', namespace,
|
log_action('delete_repo_accesstoken', namespace,
|
||||||
{'repo': repository, 'token': token.friendly_name, 'code': code},
|
{'repo': repository, 'token': token.friendly_name,
|
||||||
|
'code': code},
|
||||||
repo = model.get_repository(namespace, repository))
|
repo = model.get_repository(namespace, repository))
|
||||||
|
|
||||||
return make_response('Deleted', 204)
|
return make_response('Deleted', 204)
|
||||||
|
@ -1486,9 +1506,9 @@ def get_card(user):
|
||||||
|
|
||||||
if default_card:
|
if default_card:
|
||||||
card_info = {
|
card_info = {
|
||||||
'owner': card.name,
|
'owner': default_card.name,
|
||||||
'type': card.type,
|
'type': default_card.type,
|
||||||
'last4': card.last4
|
'last4': default_card.last4
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonify({'card': card_info})
|
return jsonify({'card': card_info})
|
||||||
|
@ -1500,7 +1520,7 @@ def subscribe_api():
|
||||||
plan = request_data['plan']
|
plan = request_data['plan']
|
||||||
token = request_data['token'] if 'token' in request_data else None
|
token = request_data['token'] if 'token' in request_data else None
|
||||||
user = current_user.db_user()
|
user = current_user.db_user()
|
||||||
return subscribe(user, plan, token, USER_PLANS)
|
return subscribe(user, plan, token, False) # Business features not required
|
||||||
|
|
||||||
|
|
||||||
def carderror_response(e):
|
def carderror_response(e):
|
||||||
|
@ -1511,15 +1531,22 @@ def carderror_response(e):
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
def subscribe(user, plan, token, accepted_plans):
|
def subscribe(user, plan, token, require_business_plan):
|
||||||
plan_found = None
|
plan_found = None
|
||||||
for plan_obj in accepted_plans:
|
for plan_obj in PLANS:
|
||||||
if plan_obj['stripeId'] == plan:
|
if plan_obj['stripeId'] == plan:
|
||||||
plan_found = plan_obj
|
plan_found = plan_obj
|
||||||
|
|
||||||
if not plan_found:
|
if not plan_found or plan_found['deprecated']:
|
||||||
|
logger.warning('Plan not found or deprecated: %s', plan)
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
if (require_business_plan and not plan_found['bus_features'] and not
|
||||||
|
plan_found['price'] == 0):
|
||||||
|
logger.warning('Business attempting to subscribe to personal plan: %s',
|
||||||
|
user.username)
|
||||||
|
abort(400)
|
||||||
|
|
||||||
private_repos = model.get_private_repo_count(user.username)
|
private_repos = model.get_private_repo_count(user.username)
|
||||||
|
|
||||||
# This is the default response
|
# This is the default response
|
||||||
|
@ -1578,9 +1605,31 @@ def subscribe(user, plan, token, accepted_plans):
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/user/invoices', methods=['GET'])
|
||||||
|
@api_login_required
|
||||||
|
def user_invoices_api():
|
||||||
|
user = current_user.db_user()
|
||||||
|
if not user.stripe_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
return get_invoices(user.stripe_id)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/organization/<orgname>/invoices', methods=['GET'])
|
@app.route('/api/organization/<orgname>/invoices', methods=['GET'])
|
||||||
@api_login_required
|
@api_login_required
|
||||||
def org_invoices_api(orgname):
|
def org_invoices_api(orgname):
|
||||||
|
permission = AdministerOrganizationPermission(orgname)
|
||||||
|
if permission.can():
|
||||||
|
organization = model.get_organization(orgname)
|
||||||
|
if not organization.stripe_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
return get_invoices(organization.stripe_id)
|
||||||
|
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
|
def get_invoices(customer_id):
|
||||||
def invoice_view(i):
|
def invoice_view(i):
|
||||||
return {
|
return {
|
||||||
'id': i.id,
|
'id': i.id,
|
||||||
|
@ -1596,18 +1645,10 @@ def org_invoices_api(orgname):
|
||||||
'plan': i.lines.data[0].plan.id if i.lines.data[0].plan else None
|
'plan': i.lines.data[0].plan.id if i.lines.data[0].plan else None
|
||||||
}
|
}
|
||||||
|
|
||||||
permission = AdministerOrganizationPermission(orgname)
|
invoices = stripe.Invoice.all(customer=customer_id, count=12)
|
||||||
if permission.can():
|
return jsonify({
|
||||||
organization = model.get_organization(orgname)
|
'invoices': [invoice_view(i) for i in invoices.data]
|
||||||
if not organization.stripe_id:
|
})
|
||||||
abort(404)
|
|
||||||
|
|
||||||
invoices = stripe.Invoice.all(customer=organization.stripe_id, count=12)
|
|
||||||
return jsonify({
|
|
||||||
'invoices': [invoice_view(i) for i in invoices.data]
|
|
||||||
})
|
|
||||||
|
|
||||||
abort(403)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/organization/<orgname>/plan', methods=['PUT'])
|
@app.route('/api/organization/<orgname>/plan', methods=['PUT'])
|
||||||
|
@ -1619,7 +1660,7 @@ def subscribe_org_api(orgname):
|
||||||
plan = request_data['plan']
|
plan = request_data['plan']
|
||||||
token = request_data['token'] if 'token' in request_data else None
|
token = request_data['token'] if 'token' in request_data else None
|
||||||
organization = model.get_organization(orgname)
|
organization = model.get_organization(orgname)
|
||||||
return subscribe(organization, plan, token, BUSINESS_PLANS)
|
return subscribe(organization, plan, token, True) # Business plan required
|
||||||
|
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
@ -1656,7 +1697,7 @@ def get_org_subscription(orgname):
|
||||||
return jsonify(subscription_view(cus.subscription, private_repos))
|
return jsonify(subscription_view(cus.subscription, private_repos))
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'plan': 'bus-free',
|
'plan': 'free',
|
||||||
'usedPrivateRepos': private_repos,
|
'usedPrivateRepos': private_repos,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -1743,20 +1784,20 @@ def delete_org_robot(orgname, robot_shortname):
|
||||||
|
|
||||||
|
|
||||||
def log_view(log):
|
def log_view(log):
|
||||||
view = {
|
view = {
|
||||||
'kind': log.kind.name,
|
'kind': log.kind.name,
|
||||||
'metadata': json.loads(log.metadata_json),
|
'metadata': json.loads(log.metadata_json),
|
||||||
'ip': log.ip,
|
'ip': log.ip,
|
||||||
'datetime': log.datetime,
|
'datetime': log.datetime,
|
||||||
|
}
|
||||||
|
|
||||||
|
if log.performer:
|
||||||
|
view['performer'] = {
|
||||||
|
'username': log.performer.username,
|
||||||
|
'is_robot': log.performer.robot,
|
||||||
}
|
}
|
||||||
|
|
||||||
if log.performer:
|
return view
|
||||||
view['performer'] = {
|
|
||||||
'username': log.performer.username,
|
|
||||||
'is_robot': log.performer.robot,
|
|
||||||
}
|
|
||||||
|
|
||||||
return view
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -1786,12 +1827,25 @@ def org_logs_api(orgname):
|
||||||
start_time = request.args.get('starttime', None)
|
start_time = request.args.get('starttime', None)
|
||||||
end_time = request.args.get('endtime', None)
|
end_time = request.args.get('endtime', None)
|
||||||
|
|
||||||
return get_logs(orgname, start_time, end_time, performer_name=performer_name)
|
return get_logs(orgname, start_time, end_time,
|
||||||
|
performer_name=performer_name)
|
||||||
|
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
def get_logs(namespace, start_time, end_time, performer_name=None, repository=None):
|
@app.route('/api/user/logs', methods=['GET'])
|
||||||
|
@api_login_required
|
||||||
|
def user_logs_api():
|
||||||
|
performer_name = request.args.get('performer', None)
|
||||||
|
start_time = request.args.get('starttime', None)
|
||||||
|
end_time = request.args.get('endtime', None)
|
||||||
|
|
||||||
|
return get_logs(current_user.db_user().username, start_time, end_time,
|
||||||
|
performer_name=performer_name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logs(namespace, start_time, end_time, performer_name=None,
|
||||||
|
repository=None):
|
||||||
performer = None
|
performer = None
|
||||||
if performer_name:
|
if performer_name:
|
||||||
performer = model.get_user(performer_name)
|
performer = model.get_user(performer_name)
|
||||||
|
@ -1815,7 +1869,8 @@ def get_logs(namespace, start_time, end_time, performer_name=None, repository=No
|
||||||
if not end_time:
|
if not end_time:
|
||||||
end_time = datetime.today()
|
end_time = datetime.today()
|
||||||
|
|
||||||
logs = model.list_logs(namespace, start_time, end_time, performer = performer, repository=repository)
|
logs = model.list_logs(namespace, start_time, end_time, performer=performer,
|
||||||
|
repository=repository)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'start_time': start_time,
|
'start_time': start_time,
|
||||||
'end_time': end_time,
|
'end_time': end_time,
|
||||||
|
|
|
@ -4,7 +4,7 @@ import stripe
|
||||||
|
|
||||||
from flask import (abort, redirect, request, url_for, render_template,
|
from flask import (abort, redirect, request, url_for, render_template,
|
||||||
make_response, Response)
|
make_response, Response)
|
||||||
from flask.ext.login import login_user, UserMixin
|
from flask.ext.login import login_user, UserMixin, current_user
|
||||||
from flask.ext.principal import identity_changed
|
from flask.ext.principal import identity_changed
|
||||||
from urlparse import urlparse
|
from urlparse import urlparse
|
||||||
|
|
||||||
|
@ -134,21 +134,34 @@ def privacy():
|
||||||
|
|
||||||
@app.route('/receipt', methods=['GET'])
|
@app.route('/receipt', methods=['GET'])
|
||||||
def receipt():
|
def receipt():
|
||||||
|
if not current_user.is_authenticated():
|
||||||
|
abort(401)
|
||||||
|
return
|
||||||
|
|
||||||
id = request.args.get('id')
|
id = request.args.get('id')
|
||||||
if id:
|
if id:
|
||||||
invoice = stripe.Invoice.retrieve(id)
|
invoice = stripe.Invoice.retrieve(id)
|
||||||
if invoice:
|
if invoice:
|
||||||
org = model.get_user_or_org_by_customer_id(invoice.customer)
|
user_or_org = model.get_user_or_org_by_customer_id(invoice.customer)
|
||||||
if org and org.organization:
|
|
||||||
admin_org = AdministerOrganizationPermission(org.username)
|
if user_or_org:
|
||||||
if admin_org.can():
|
if user_or_org.organization:
|
||||||
file_data = renderInvoiceToPdf(invoice, org)
|
admin_org = AdministerOrganizationPermission(user_or_org.username)
|
||||||
return Response(file_data,
|
if not admin_org.can():
|
||||||
mimetype="application/pdf",
|
abort(404)
|
||||||
headers={"Content-Disposition":
|
return
|
||||||
"attachment;filename=receipt.pdf"})
|
else:
|
||||||
|
if not user_or_org.username == current_user.db_user().username:
|
||||||
|
abort(404)
|
||||||
|
return
|
||||||
|
|
||||||
|
file_data = renderInvoiceToPdf(invoice, user_or_org)
|
||||||
|
return Response(file_data,
|
||||||
|
mimetype="application/pdf",
|
||||||
|
headers={"Content-Disposition": "attachment;filename=receipt.pdf"})
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
|
||||||
def common_login(db_user):
|
def common_login(db_user):
|
||||||
if login_user(_LoginWrappedDBUser(db_user.username, db_user)):
|
if login_user(_LoginWrappedDBUser(db_user.username, db_user)):
|
||||||
logger.debug('Successfully signed in as: %s' % db_user.username)
|
logger.debug('Successfully signed in as: %s' % db_user.username)
|
||||||
|
|
|
@ -1,19 +1,14 @@
|
||||||
import logging
|
import logging
|
||||||
import requests
|
|
||||||
import stripe
|
import stripe
|
||||||
|
|
||||||
from flask import (abort, redirect, request, url_for, render_template,
|
from flask import request, make_response
|
||||||
make_response)
|
|
||||||
from flask.ext.login import login_user, UserMixin, login_required
|
|
||||||
from flask.ext.principal import identity_changed, Identity, AnonymousIdentity
|
|
||||||
|
|
||||||
from data import model
|
from data import model
|
||||||
from app import app, login_manager, mixpanel
|
from app import app
|
||||||
from auth.permissions import QuayDeferredPermissionUser
|
|
||||||
from data.plans import USER_PLANS, BUSINESS_PLANS, get_plan
|
|
||||||
from util.invoice import renderInvoiceToHtml
|
from util.invoice import renderInvoiceToHtml
|
||||||
from util.email import send_invoice_email
|
from util.email import send_invoice_email
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@ -33,10 +28,10 @@ def stripe_webhook():
|
||||||
# Find the user associated with the customer ID.
|
# Find the user associated with the customer ID.
|
||||||
user = model.get_user_or_org_by_customer_id(customer_id)
|
user = model.get_user_or_org_by_customer_id(customer_id)
|
||||||
if user and user.invoice_email:
|
if user and user.invoice_email:
|
||||||
# Lookup the invoice.
|
# Lookup the invoice.
|
||||||
invoice = stripe.Invoice.retrieve(invoice_id)
|
invoice = stripe.Invoice.retrieve(invoice_id)
|
||||||
if invoice:
|
if invoice:
|
||||||
invoice_html = renderInvoiceToHtml(invoice, user)
|
invoice_html = renderInvoiceToHtml(invoice, user)
|
||||||
send_invoice_email(user.email, invoice_html)
|
send_invoice_email(user.email, invoice_html)
|
||||||
|
|
||||||
return make_response('Okay')
|
return make_response('Okay')
|
||||||
|
|
|
@ -104,10 +104,6 @@ html, body {
|
||||||
word-wrap: normal !important;
|
word-wrap: normal !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-info {
|
|
||||||
border-bottom: 1px dashed #aaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-icon {
|
.toggle-icon {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
@ -662,49 +658,165 @@ i.toggle-icon:hover {
|
||||||
|
|
||||||
.plans-list .plan {
|
.plans-list .plan {
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
border-top: 4px solid #94C9F7;
|
|
||||||
font-size: 1.4em;
|
font-size: 1.4em;
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plans-list .plan.small {
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-top: 4px solid #428bca;
|
|
||||||
margin-top: 0px;
|
|
||||||
font-size: 1.6em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plans-list .plan.business-plan {
|
|
||||||
border: 1px solid #eee;
|
|
||||||
border-top: 4px solid #94F794;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plans-list .plan.bus-small {
|
.plans-list .plan.bus-small {
|
||||||
border: 1px solid #ddd;
|
border-top: 6px solid #46ac39;
|
||||||
border-top: 4px solid #47A447;
|
margin-top: -10px;
|
||||||
margin-top: 0px;
|
}
|
||||||
font-size: 1.6em;
|
|
||||||
|
.plans-list .plan.bus-small .plan-box {
|
||||||
|
background: black !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plans-list .plan:last-child {
|
.plans-list .plan:last-child {
|
||||||
margin-right: 0px;
|
margin-right: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plans-list .plan .plan-box {
|
||||||
|
background: #444;
|
||||||
|
padding: 10px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.plans-list .plan .plan-title {
|
.plans-list .plan .plan-title {
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding-top: 25px;
|
||||||
|
padding-bottom: 20px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
display: block;
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visible-sm-inline {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-sm-inline {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991px) and (min-width: 768px) {
|
||||||
|
.visible-sm-inline {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-sm-inline {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-list .plan-box .description {
|
||||||
|
color: white;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-list .plan button {
|
||||||
|
margin-top: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-list .plan.bus-small button {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-list .features-bar {
|
||||||
|
padding-top: 248px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-list .features-bar .feature .count {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-list .features-bar .feature {
|
||||||
|
height: 43px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-tooltip {
|
||||||
|
border-bottom: 1px dotted black;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-list .features-bar .feature i {
|
||||||
|
margin-left: 16px;
|
||||||
|
float: right;
|
||||||
|
width: 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-list .plan .features {
|
||||||
|
padding: 6px;
|
||||||
|
background: #eee;
|
||||||
|
padding-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-list .plan .feature {
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-list .plan .feature:after {
|
||||||
|
content: "";
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-list .plan .visible-xs .feature {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-list .plan .visible-xs .feature:after {
|
||||||
|
float: left;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-list .plan .feature.notpresent {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-list .plan .feature.present:after {
|
||||||
|
background: #428bca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-list .plan.business-plan .feature.present:after {
|
||||||
|
background: #46ac39;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-list .plan .count, .plans-list .features-bar .count {
|
||||||
|
background: white;
|
||||||
|
border-bottom: 0px;
|
||||||
|
text-align: center !important;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-list .plan .count b, .plans-list .features-bar .count b {
|
||||||
|
font-size: 1.5em;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-list .plan .feature:last-child {
|
||||||
|
border-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-list .plan-price {
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-price {
|
.plan-price {
|
||||||
margin-bottom: 10px;
|
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.8em;
|
font-size: 1.8em;
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -732,7 +844,8 @@ i.toggle-icon:hover {
|
||||||
.plans-list .plan .description {
|
.plans-list .plan .description {
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
margin-bottom: 10px;
|
height: 34px;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plans-list .plan .smaller {
|
.plans-list .plan .smaller {
|
||||||
|
@ -1926,28 +2039,28 @@ p.editable:hover i {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.org-admin .invoice-title {
|
.billing-invoices-element .invoice-title {
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.org-admin .invoice-status .success {
|
.billing-invoices-element .invoice-status .success {
|
||||||
color: green;
|
color: green;
|
||||||
}
|
}
|
||||||
|
|
||||||
.org-admin .invoice-status .pending {
|
.billing-invoices-element .invoice-status .pending {
|
||||||
color: steelblue;
|
color: steelblue;
|
||||||
}
|
}
|
||||||
|
|
||||||
.org-admin .invoice-status .danger {
|
.billing-invoices-element .invoice-status .danger {
|
||||||
color: red;
|
color: red;
|
||||||
}
|
}
|
||||||
|
|
||||||
.org-admin .invoice-amount:before {
|
.billing-invoices-element .invoice-amount:before {
|
||||||
content: '$';
|
content: '$';
|
||||||
}
|
}
|
||||||
|
|
||||||
.org-admin .invoice-details {
|
.billing-invoices-element .invoice-details {
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
@ -1956,21 +2069,21 @@ p.editable:hover i {
|
||||||
border-left: 2px solid #eee !important;
|
border-left: 2px solid #eee !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.org-admin .invoice-details td {
|
.billing-invoices-element .invoice-details td {
|
||||||
border: 0px solid transparent !important;
|
border: 0px solid transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.org-admin .invoice-details dl {
|
.billing-invoices-element .invoice-details dl {
|
||||||
margin: 0px;
|
margin: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.org-admin .invoice-details dd {
|
.billing-invoices-element .invoice-details dd {
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.org-admin .invoice-title:hover {
|
.billing-invoices-element .invoice-title:hover {
|
||||||
color: steelblue;
|
color: steelblue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2096,6 +2209,14 @@ p.editable:hover i {
|
||||||
margin-bottom: 0px;
|
margin-bottom: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plan-manager-element .plans-list-table .deprecated-plan {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-manager-element .plans-list-table .deprecated-plan-label {
|
||||||
|
font-size: 0.7em;
|
||||||
|
}
|
||||||
|
|
||||||
.plans-table-element table {
|
.plans-table-element table {
|
||||||
margin: 20px;
|
margin: 20px;
|
||||||
border: 1px solid #eee;
|
border: 1px solid #eee;
|
||||||
|
|
53
static/directives/billing-invoices.html
Normal file
53
static/directives/billing-invoices.html
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
<div class="billing-invoices-element">
|
||||||
|
<div ng-show="loading">
|
||||||
|
<div class="quay-spinner"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ng-show="!loading && !invoices">
|
||||||
|
No invoices have been created
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ng-show="!loading && invoices">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<th>Billing Date/Time</th>
|
||||||
|
<th>Amount Due</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody class="invoice" ng-repeat="invoice in invoices">
|
||||||
|
<tr class="invoice-title">
|
||||||
|
<td ng-click="toggleInvoice(invoice.id)"><span class="invoice-datetime">{{ invoice.date * 1000 | date:'medium' }}</span></td>
|
||||||
|
<td ng-click="toggleInvoice(invoice.id)"><span class="invoice-amount">{{ invoice.amount_due / 100 }}</span></td>
|
||||||
|
<td>
|
||||||
|
<span class="invoice-status">
|
||||||
|
<span class="success" ng-show="invoice.paid">Paid - Thank you!</span>
|
||||||
|
<span class="danger" ng-show="!invoice.paid && invoice.attempted && invoice.closed">Payment failed</span>
|
||||||
|
<span class="danger" ng-show="!invoice.paid && invoice.attempted && !invoice.closed">Payment failed - Will retry soon</span>
|
||||||
|
<span class="pending" ng-show="!invoice.paid && !invoice.attempted">Payment pending</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a ng-show="invoice.paid" href="/receipt?id={{ invoice.id }}" download="receipt.pdf" target="_new">
|
||||||
|
<i class="fa fa-download" title="Download Receipt" bs-tooltip="tooltip.title"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr ng-class="invoiceExpanded[invoice.id] ? 'in' : 'out'" class="invoice-details panel-collapse collapse">
|
||||||
|
<td colspan="3">
|
||||||
|
<dl class="dl-normal">
|
||||||
|
<dt>Billing Period</dt>
|
||||||
|
<dd>
|
||||||
|
<span>{{ invoice.period_start * 1000 | date:'mediumDate' }}</span> -
|
||||||
|
<span>{{ invoice.period_end * 1000 | date:'mediumDate' }}</span>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
|
@ -7,7 +7,7 @@
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<div class="quay-spinner" ng-show="!currentCard || changingCard"></div>
|
<div class="quay-spinner" ng-show="!currentCard || changingCard"></div>
|
||||||
<div class="current-card" ng-show="currentCard && !changingCard">
|
<div class="current-card" ng-show="currentCard && !changingCard">
|
||||||
<img src="{{ '/static/img/creditcards/' + getCreditImage(currentCard) }}" ng-show="currentCard.last4">
|
<img ng-src="{{ '/static/img/creditcards/' + getCreditImage(currentCard) }}" ng-show="currentCard.last4">
|
||||||
<span class="no-card-outline" ng-show="!currentCard.last4"></span>
|
<span class="no-card-outline" ng-show="!currentCard.last4"></span>
|
||||||
|
|
||||||
<span class="last4" ng-show="currentCard.last4">****-****-****-<b>{{ currentCard.last4 }}</b></span>
|
<span class="last4" ng-show="currentCard.last4">****-****-****-<b>{{ currentCard.last4 }}</b></span>
|
||||||
|
|
|
@ -38,7 +38,10 @@
|
||||||
<a href="javascript:void(0)" class="dropdown-toggle user-dropdown" data-toggle="dropdown">
|
<a href="javascript:void(0)" class="dropdown-toggle user-dropdown" data-toggle="dropdown">
|
||||||
<img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=32&d=identicon" />
|
<img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=32&d=identicon" />
|
||||||
{{ user.username }}
|
{{ user.username }}
|
||||||
<span class="badge user-notification notification-animated" ng-show="user.askForPassword || overPlan">
|
<span class="badge user-notification notification-animated" ng-show="user.askForPassword || overPlan"
|
||||||
|
bs-tooltip="(user.askForPassword ? 'A password is needed for this account<br>' : '') + (overPlan ? 'You are using more private repositories than your plan allows' : '')"
|
||||||
|
data-placement="left"
|
||||||
|
data-container="body">
|
||||||
{{ (user.askForPassword ? 1 : 0) + (overPlan ? 1 : 0) }}
|
{{ (user.askForPassword ? 1 : 0) + (overPlan ? 1 : 0) }}
|
||||||
</span>
|
</span>
|
||||||
<b class="caret"></b>
|
<b class="caret"></b>
|
||||||
|
|
|
@ -32,14 +32,23 @@
|
||||||
<td></td>
|
<td></td>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tr ng-repeat="plan in plans" ng-class="(subscribedPlan.stripeId === plan.stripeId) ? getActiveSubClass() : ''">
|
<tr ng-repeat="plan in plans" ng-show="isPlanVisible(plan, subscribedPlan)"
|
||||||
<td>{{ plan.title }}</td>
|
ng-class="{'active':(subscribedPlan.stripeId === plan.stripeId), 'deprecated-plan':plan.deprecated}">
|
||||||
|
<td>
|
||||||
|
{{ plan.title }}
|
||||||
|
<div class="deprecated-plan-label" ng-show="plan.deprecated">
|
||||||
|
<span class="context-tooltip" title="This plan has been discontinued. As a valued early adopter, you may continue to stay on this plan indefinitely." bs-tooltip="tooltip.title" data-placement="right">Discontinued Plan</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td>{{ plan.privateRepos }}</td>
|
<td>{{ plan.privateRepos }}</td>
|
||||||
<td><div class="plan-price">${{ plan.price / 100 }}</div></td>
|
<td><div class="plan-price">${{ plan.price / 100 }}</div></td>
|
||||||
<td class="controls">
|
<td class="controls">
|
||||||
<div ng-switch='plan.stripeId'>
|
<div ng-switch='plan.deprecated'>
|
||||||
<div ng-switch-when='bus-free'>
|
<div ng-switch-when='true'>
|
||||||
<button class="btn button-hidden">Hidden!</button>
|
<button class="btn btn-danger" ng-click="cancelSubscription()">
|
||||||
|
<span class="quay-spinner" ng-show="planChanging"></span>
|
||||||
|
<span ng-show="!planChanging">Cancel</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div ng-switch-default>
|
<div ng-switch-default>
|
||||||
<button class="btn" ng-show="subscribedPlan.stripeId !== plan.stripeId"
|
<button class="btn" ng-show="subscribedPlan.stripeId !== plan.stripeId"
|
||||||
|
|
217
static/js/app.js
217
static/js/app.js
|
@ -261,6 +261,10 @@ quayApp = angular.module('quay', ['ngRoute', 'chieffancypants.loadingBar', 'rest
|
||||||
var planService = {};
|
var planService = {};
|
||||||
var listeners = [];
|
var listeners = [];
|
||||||
|
|
||||||
|
planService.getFreePlan = function() {
|
||||||
|
return 'free';
|
||||||
|
};
|
||||||
|
|
||||||
planService.registerListener = function(obj, callback) {
|
planService.registerListener = function(obj, callback) {
|
||||||
listeners.push({'obj': obj, 'callback': callback});
|
listeners.push({'obj': obj, 'callback': callback});
|
||||||
};
|
};
|
||||||
|
@ -278,6 +282,27 @@ quayApp = angular.module('quay', ['ngRoute', 'chieffancypants.loadingBar', 'rest
|
||||||
CookieService.putSession('quay.notedplan', planId);
|
CookieService.putSession('quay.notedplan', planId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
planService.isOrgCompatible = function(plan) {
|
||||||
|
return plan['stripeId'] == planService.getFreePlan() || plan['bus_features'];
|
||||||
|
};
|
||||||
|
|
||||||
|
planService.getMatchingBusinessPlan = function(callback) {
|
||||||
|
planService.getPlans(function() {
|
||||||
|
planService.getSubscription(null, function(sub) {
|
||||||
|
var plan = planDict[sub.plan];
|
||||||
|
if (!plan) {
|
||||||
|
planService.getMinimumPlan(0, true, callback);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var count = Math.max(sub.usedPrivateRepos, plan.privateRepos);
|
||||||
|
planService.getMinimumPlan(count, true, callback);
|
||||||
|
}, function() {
|
||||||
|
planService.getMinimumPlan(0, true, callback);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
planService.handleNotedPlan = function() {
|
planService.handleNotedPlan = function() {
|
||||||
var planId = planService.getAndResetNotedPlan();
|
var planId = planService.getAndResetNotedPlan();
|
||||||
if (!planId) { return; }
|
if (!planId) { return; }
|
||||||
|
@ -288,13 +313,11 @@ quayApp = angular.module('quay', ['ngRoute', 'chieffancypants.loadingBar', 'rest
|
||||||
}
|
}
|
||||||
|
|
||||||
planService.getPlan(planId, function(plan) {
|
planService.getPlan(planId, function(plan) {
|
||||||
planService.isBusinessPlan(planId, function(bus) {
|
if (planService.isOrgCompatible(plan)) {
|
||||||
if (bus) {
|
document.location = '/organizations/new/?plan=' + planId;
|
||||||
document.location = '/organizations/new/?plan=' + planId;
|
} else {
|
||||||
} else {
|
document.location = '/user?plan=' + planId;
|
||||||
document.location = '/user?plan=' + planId;
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -333,69 +356,51 @@ quayApp = angular.module('quay', ['ngRoute', 'chieffancypants.loadingBar', 'rest
|
||||||
var getPlans = Restangular.one('plans');
|
var getPlans = Restangular.one('plans');
|
||||||
getPlans.get().then(function(data) {
|
getPlans.get().then(function(data) {
|
||||||
var i = 0;
|
var i = 0;
|
||||||
for(i = 0; i < data.user.length; i++) {
|
for(i = 0; i < data.plans.length; i++) {
|
||||||
planDict[data.user[i].stripeId] = data.user[i];
|
planDict[data.plans[i].stripeId] = data.plans[i];
|
||||||
}
|
}
|
||||||
for(i = 0; i < data.business.length; i++) {
|
plans = data.plans;
|
||||||
planDict[data.business[i].stripeId] = data.business[i];
|
|
||||||
}
|
|
||||||
plans = data;
|
|
||||||
callback(plans);
|
callback(plans);
|
||||||
}, function() { callback([]); });
|
}, function() { callback([]); });
|
||||||
};
|
};
|
||||||
|
|
||||||
planService.getMatchingBusinessPlan = function(callback) {
|
planService.getPlans = function(callback, opt_includePersonal) {
|
||||||
planService.getPlans(function() {
|
|
||||||
planService.getSubscription(null, function(sub) {
|
|
||||||
var plan = planDict[sub.plan];
|
|
||||||
if (!plan) {
|
|
||||||
planService.getMinimumPlan(0, true, callback);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var count = Math.max(sub.usedPrivateRepos, plan.privateRepos);
|
|
||||||
planService.getMinimumPlan(count, true, callback);
|
|
||||||
}, function() {
|
|
||||||
planService.getMinimumPlan(0, true, callback);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
planService.isBusinessPlan = function(planId, callback) {
|
|
||||||
planService.verifyLoaded(function() {
|
planService.verifyLoaded(function() {
|
||||||
planSource = plans.business;
|
var filtered = [];
|
||||||
for (var i = 0; i < planSource.length; i++) {
|
for (var i = 0; i < plans.length; ++i) {
|
||||||
var plan = planSource[i];
|
var plan = plans[i];
|
||||||
if (plan.stripeId == planId) {
|
if (plan['deprecated']) { continue; }
|
||||||
callback(true);
|
if (!opt_includePersonal && !planService.isOrgCompatible(plan)) { continue; }
|
||||||
return;
|
filtered.push(plan);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
callback(false);
|
callback(filtered);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
planService.getPlans = function(callback) {
|
|
||||||
planService.verifyLoaded(callback);
|
|
||||||
};
|
|
||||||
|
|
||||||
planService.getPlan = function(planId, callback) {
|
planService.getPlan = function(planId, callback) {
|
||||||
|
planService.getPlanIncludingDeprecated(planId, function(plan) {
|
||||||
|
if (!plan['deprecated']) {
|
||||||
|
callback(plan);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
planService.getPlanIncludingDeprecated = function(planId, callback) {
|
||||||
planService.verifyLoaded(function() {
|
planService.verifyLoaded(function() {
|
||||||
if (planDict[planId]) {
|
if (planDict[planId]) {
|
||||||
callback(planDict[planId]);
|
callback(planDict[planId]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
planService.getMinimumPlan = function(privateCount, isBusiness, callback) {
|
planService.getMinimumPlan = function(privateCount, isBusiness, callback) {
|
||||||
planService.verifyLoaded(function() {
|
planService.getPlans(function(plans) {
|
||||||
var planSource = plans.user;
|
for (var i = 0; i < plans.length; i++) {
|
||||||
if (isBusiness) {
|
var plan = plans[i];
|
||||||
planSource = plans.business;
|
if (isBusiness && !planService.isOrgCompatible(plan)) {
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
for (var i = 0; i < planSource.length; i++) {
|
|
||||||
var plan = planSource[i];
|
|
||||||
if (plan.privateRepos >= privateCount) {
|
if (plan.privateRepos >= privateCount) {
|
||||||
callback(plan);
|
callback(plan);
|
||||||
return;
|
return;
|
||||||
|
@ -453,6 +458,8 @@ quayApp = angular.module('quay', ['ngRoute', 'chieffancypants.loadingBar', 'rest
|
||||||
}
|
}
|
||||||
|
|
||||||
planService.getPlan(planId, function(plan) {
|
planService.getPlan(planId, function(plan) {
|
||||||
|
if (orgname && !planService.isOrgCompatible(plan)) { return; }
|
||||||
|
|
||||||
planService.getCardInfo(orgname, function(cardInfo) {
|
planService.getCardInfo(orgname, function(cardInfo) {
|
||||||
if (plan.price > 0 && !cardInfo.last4) {
|
if (plan.price > 0 && !cardInfo.last4) {
|
||||||
planService.showSubscribeDialog($scope, orgname, planId, callbacks);
|
planService.showSubscribeDialog($scope, orgname, planId, callbacks);
|
||||||
|
@ -962,6 +969,59 @@ quayApp.filter('visibleLogFilter', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
quayApp.directive('billingInvoices', function () {
|
||||||
|
var directiveDefinitionObject = {
|
||||||
|
priority: 0,
|
||||||
|
templateUrl: '/static/directives/billing-invoices.html',
|
||||||
|
replace: false,
|
||||||
|
transclude: false,
|
||||||
|
restrict: 'C',
|
||||||
|
scope: {
|
||||||
|
'organization': '=organization',
|
||||||
|
'user': '=user',
|
||||||
|
'visible': '=visible'
|
||||||
|
},
|
||||||
|
controller: function($scope, $element, $sce, Restangular) {
|
||||||
|
$scope.loading = false;
|
||||||
|
$scope.invoiceExpanded = {};
|
||||||
|
|
||||||
|
$scope.toggleInvoice = function(id) {
|
||||||
|
$scope.invoiceExpanded[id] = !$scope.invoiceExpanded[id];
|
||||||
|
};
|
||||||
|
|
||||||
|
var update = function() {
|
||||||
|
var hasValidUser = !!$scope.user;
|
||||||
|
var hasValidOrg = !!$scope.organization;
|
||||||
|
var isValid = hasValidUser || hasValidOrg;
|
||||||
|
|
||||||
|
if (!$scope.visible || !isValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope.loading = true;
|
||||||
|
|
||||||
|
var url = getRestUrl('user/invoices');
|
||||||
|
if ($scope.organization) {
|
||||||
|
url = getRestUrl('organization', $scope.organization.name, 'invoices');
|
||||||
|
}
|
||||||
|
|
||||||
|
var getInvoices = Restangular.one(url);
|
||||||
|
getInvoices.get().then(function(resp) {
|
||||||
|
$scope.invoices = resp.invoices;
|
||||||
|
$scope.loading = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.$watch('organization', update);
|
||||||
|
$scope.$watch('user', update);
|
||||||
|
$scope.$watch('visible', update);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return directiveDefinitionObject;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
quayApp.directive('logsView', function () {
|
quayApp.directive('logsView', function () {
|
||||||
var directiveDefinitionObject = {
|
var directiveDefinitionObject = {
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
@ -1843,8 +1903,16 @@ quayApp.directive('planManager', function () {
|
||||||
controller: function($scope, $element, PlanService, Restangular) {
|
controller: function($scope, $element, PlanService, Restangular) {
|
||||||
var hasSubscription = false;
|
var hasSubscription = false;
|
||||||
|
|
||||||
$scope.getActiveSubClass = function() {
|
$scope.isPlanVisible = function(plan, subscribedPlan) {
|
||||||
return 'active';
|
if (plan['deprecated']) {
|
||||||
|
return plan == subscribedPlan;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scope.organization && !PlanService.isOrgCompatible(plan)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.changeSubscription = function(planId) {
|
$scope.changeSubscription = function(planId) {
|
||||||
|
@ -1865,17 +1933,17 @@ quayApp.directive('planManager', function () {
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.cancelSubscription = function() {
|
$scope.cancelSubscription = function() {
|
||||||
$scope.changeSubscription(getFreePlan());
|
$scope.changeSubscription(PlanService.getFreePlan());
|
||||||
};
|
};
|
||||||
|
|
||||||
var subscribedToPlan = function(sub) {
|
var subscribedToPlan = function(sub) {
|
||||||
$scope.subscription = sub;
|
$scope.subscription = sub;
|
||||||
|
|
||||||
if (sub.plan != getFreePlan()) {
|
if (sub.plan != PlanService.getFreePlan()) {
|
||||||
hasSubscription = true;
|
hasSubscription = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
PlanService.getPlan(sub.plan, function(subscribedPlan) {
|
PlanService.getPlanIncludingDeprecated(sub.plan, function(subscribedPlan) {
|
||||||
$scope.subscribedPlan = subscribedPlan;
|
$scope.subscribedPlan = subscribedPlan;
|
||||||
$scope.planUsagePercent = sub.usedPrivateRepos * 100 / $scope.subscribedPlan.privateRepos;
|
$scope.planUsagePercent = sub.usedPrivateRepos * 100 / $scope.subscribedPlan.privateRepos;
|
||||||
|
|
||||||
|
@ -1905,22 +1973,13 @@ quayApp.directive('planManager', function () {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
var getFreePlan = function() {
|
|
||||||
for (var i = 0; i < $scope.plans.length; ++i) {
|
|
||||||
if ($scope.plans[i].price == 0) {
|
|
||||||
return $scope.plans[i].stripeId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'free';
|
|
||||||
};
|
|
||||||
|
|
||||||
var update = function() {
|
var update = function() {
|
||||||
$scope.planLoading = true;
|
$scope.planLoading = true;
|
||||||
if (!$scope.plans) { return; }
|
if (!$scope.plans) { return; }
|
||||||
|
|
||||||
PlanService.getSubscription($scope.organization, subscribedToPlan, function() {
|
PlanService.getSubscription($scope.organization, subscribedToPlan, function() {
|
||||||
// User/Organization has no subscription.
|
// User/Organization has no subscription.
|
||||||
subscribedToPlan({ 'plan': getFreePlan() });
|
subscribedToPlan({ 'plan': PlanService.getFreePlan() });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1929,13 +1988,13 @@ quayApp.directive('planManager', function () {
|
||||||
if (!$scope.user && !$scope.organization) { return; }
|
if (!$scope.user && !$scope.organization) { return; }
|
||||||
|
|
||||||
$scope.loadingPlans = true;
|
$scope.loadingPlans = true;
|
||||||
PlanService.getPlans(function(plans) {
|
PlanService.verifyLoaded(function(plans) {
|
||||||
$scope.plans = plans[$scope.organization ? 'business' : 'user'];
|
$scope.plans = plans;
|
||||||
update();
|
update();
|
||||||
|
|
||||||
if ($scope.readyForPlan) {
|
if ($scope.readyForPlan) {
|
||||||
var planRequested = $scope.readyForPlan();
|
var planRequested = $scope.readyForPlan();
|
||||||
if (planRequested && planRequested != getFreePlan()) {
|
if (planRequested && planRequested != PlanService.getFreePlan()) {
|
||||||
$scope.changeSubscription(planRequested);
|
$scope.changeSubscription(planRequested);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2102,15 +2161,27 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi
|
||||||
// Check if we need to redirect based on a previously chosen plan.
|
// Check if we need to redirect based on a previously chosen plan.
|
||||||
PlanService.handleNotedPlan();
|
PlanService.handleNotedPlan();
|
||||||
|
|
||||||
var changeTab = function(activeTab) {
|
var changeTab = function(activeTab, opt_timeout) {
|
||||||
|
var checkCount = 0;
|
||||||
|
|
||||||
$timeout(function() {
|
$timeout(function() {
|
||||||
|
if (checkCount > 5) { return; }
|
||||||
|
checkCount++;
|
||||||
|
|
||||||
$('a[data-toggle="tab"]').each(function(index) {
|
$('a[data-toggle="tab"]').each(function(index) {
|
||||||
var tabName = this.getAttribute('data-target').substr(1);
|
var tabName = this.getAttribute('data-target').substr(1);
|
||||||
if (tabName == activeTab) {
|
if (tabName != activeTab) {
|
||||||
this.click();
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.clientWidth == 0) {
|
||||||
|
changeTab(activeTab, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.click();
|
||||||
});
|
});
|
||||||
});
|
}, opt_timeout);
|
||||||
};
|
};
|
||||||
|
|
||||||
var resetDefaultTab = function() {
|
var resetDefaultTab = function() {
|
||||||
|
|
|
@ -22,7 +22,7 @@ function PlansCtrl($scope, $location, UserService, PlanService) {
|
||||||
// Load the list of plans.
|
// Load the list of plans.
|
||||||
PlanService.getPlans(function(plans) {
|
PlanService.getPlans(function(plans) {
|
||||||
$scope.plans = plans;
|
$scope.plans = plans;
|
||||||
});
|
}, /* include the personal plan */ true);
|
||||||
|
|
||||||
// Monitor any user changes and place the current user into the scope.
|
// Monitor any user changes and place the current user into the scope.
|
||||||
UserService.updateUserIn($scope);
|
UserService.updateUserIn($scope);
|
||||||
|
@ -33,19 +33,10 @@ function PlansCtrl($scope, $location, UserService, PlanService) {
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.buyNow = function(plan) {
|
$scope.buyNow = function(plan) {
|
||||||
|
PlanService.notePlan(plan);
|
||||||
if ($scope.user && !$scope.user.anonymous) {
|
if ($scope.user && !$scope.user.anonymous) {
|
||||||
document.location = '/user?plan=' + plan;
|
PlanService.handleNotedPlan();
|
||||||
} else {
|
} else {
|
||||||
PlanService.notePlan(plan);
|
|
||||||
$('#signinModal').modal({});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.createOrg = function(plan) {
|
|
||||||
if ($scope.user && !$scope.user.anonymous) {
|
|
||||||
document.location = '/organizations/new/?plan=' + plan;
|
|
||||||
} else {
|
|
||||||
PlanService.notePlan(plan);
|
|
||||||
$('#signinModal').modal({});
|
$('#signinModal').modal({});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -611,8 +602,22 @@ function UserAdminCtrl($scope, $timeout, $location, Restangular, PlanService, Us
|
||||||
|
|
||||||
$('.form-change-pw').popover();
|
$('.form-change-pw').popover();
|
||||||
|
|
||||||
|
$scope.logsShown = 0;
|
||||||
|
$scope.invoicesShown = 0;
|
||||||
|
|
||||||
|
$scope.loadLogs = function() {
|
||||||
|
if (!$scope.hasPaidBusinessPlan) { return; }
|
||||||
|
$scope.logsShown++;
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.loadInvoices = function() {
|
||||||
|
if (!$scope.hasPaidBusinessPlan) { return; }
|
||||||
|
$scope.invoicesShown++;
|
||||||
|
};
|
||||||
|
|
||||||
$scope.planChanged = function(plan) {
|
$scope.planChanged = function(plan) {
|
||||||
$scope.hasPaidPlan = plan && plan.price > 0;
|
$scope.hasPaidPlan = plan && plan.price > 0;
|
||||||
|
$scope.hasPaidBusinessPlan = PlanService.isOrgCompatible(plan) && plan.price > 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.showConvertForm = function() {
|
$scope.showConvertForm = function() {
|
||||||
|
@ -621,7 +626,7 @@ function UserAdminCtrl($scope, $timeout, $location, Restangular, PlanService, Us
|
||||||
});
|
});
|
||||||
|
|
||||||
PlanService.getPlans(function(plans) {
|
PlanService.getPlans(function(plans) {
|
||||||
$scope.orgPlans = plans.business;
|
$scope.orgPlans = plans;
|
||||||
});
|
});
|
||||||
|
|
||||||
$scope.convertStep = 1;
|
$scope.convertStep = 1;
|
||||||
|
@ -990,11 +995,6 @@ function NewRepoCtrl($scope, $location, $http, $timeout, UserService, Restangula
|
||||||
function OrgViewCtrl($rootScope, $scope, Restangular, ApiService, $routeParams) {
|
function OrgViewCtrl($rootScope, $scope, Restangular, ApiService, $routeParams) {
|
||||||
var orgname = $routeParams.orgname;
|
var orgname = $routeParams.orgname;
|
||||||
|
|
||||||
$('.info-icon').popover({
|
|
||||||
'trigger': 'hover',
|
|
||||||
'html': true
|
|
||||||
});
|
|
||||||
|
|
||||||
$scope.TEAM_PATTERN = TEAM_PATTERN;
|
$scope.TEAM_PATTERN = TEAM_PATTERN;
|
||||||
$rootScope.title = 'Loading...';
|
$rootScope.title = 'Loading...';
|
||||||
|
|
||||||
|
@ -1062,6 +1062,11 @@ function OrgViewCtrl($rootScope, $scope, Restangular, ApiService, $routeParams)
|
||||||
$scope.organization = org;
|
$scope.organization = org;
|
||||||
$rootScope.title = orgname;
|
$rootScope.title = orgname;
|
||||||
$rootScope.description = 'Viewing organization ' + orgname;
|
$rootScope.description = 'Viewing organization ' + orgname;
|
||||||
|
|
||||||
|
$('.info-icon').popover({
|
||||||
|
'trigger': 'hover',
|
||||||
|
'html': true
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1074,17 +1079,12 @@ function OrgAdminCtrl($rootScope, $scope, Restangular, $routeParams, UserService
|
||||||
|
|
||||||
// Load the list of plans.
|
// Load the list of plans.
|
||||||
PlanService.getPlans(function(plans) {
|
PlanService.getPlans(function(plans) {
|
||||||
$scope.plans = plans.business;
|
$scope.plans = plans;
|
||||||
$scope.plan_map = {};
|
$scope.plan_map = {};
|
||||||
|
|
||||||
var addPlans = function(plans) {
|
for (var i = 0; i < plans.length; ++i) {
|
||||||
for (var i = 0; i < plans.length; ++i) {
|
$scope.plan_map[plans[i].stripeId] = plans[i];
|
||||||
$scope.plan_map[plans[i].stripeId] = plans[i];
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
addPlans(plans.user);
|
|
||||||
addPlans(plans.business);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$scope.orgname = orgname;
|
$scope.orgname = orgname;
|
||||||
|
@ -1092,31 +1092,20 @@ function OrgAdminCtrl($rootScope, $scope, Restangular, $routeParams, UserService
|
||||||
$scope.membersFound = null;
|
$scope.membersFound = null;
|
||||||
$scope.invoiceLoading = true;
|
$scope.invoiceLoading = true;
|
||||||
$scope.logsShown = 0;
|
$scope.logsShown = 0;
|
||||||
|
$scope.invoicesShown = 0;
|
||||||
|
|
||||||
$scope.loadLogs = function() {
|
$scope.loadLogs = function() {
|
||||||
$scope.logsShown++;
|
$scope.logsShown++;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$scope.loadInvoices = function() {
|
||||||
|
$scope.invoicesShown++;
|
||||||
|
};
|
||||||
|
|
||||||
$scope.planChanged = function(plan) {
|
$scope.planChanged = function(plan) {
|
||||||
$scope.hasPaidPlan = plan && plan.price > 0;
|
$scope.hasPaidPlan = plan && plan.price > 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.loadInvoices = function() {
|
|
||||||
if ($scope.invoices) { return; }
|
|
||||||
$scope.invoiceLoading = true;
|
|
||||||
|
|
||||||
var getInvoices = Restangular.one(getRestUrl('organization', orgname, 'invoices'));
|
|
||||||
getInvoices.get().then(function(resp) {
|
|
||||||
$scope.invoiceExpanded = {};
|
|
||||||
$scope.invoices = resp.invoices;
|
|
||||||
$scope.invoiceLoading = false;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.toggleInvoice = function(id) {
|
|
||||||
$scope.invoiceExpanded[id] = !$scope.invoiceExpanded[id];
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.loadMembers = function() {
|
$scope.loadMembers = function() {
|
||||||
if ($scope.membersFound) { return; }
|
if ($scope.membersFound) { return; }
|
||||||
$scope.membersLoading = true;
|
$scope.membersLoading = true;
|
||||||
|
@ -1230,7 +1219,7 @@ function NewOrgCtrl($scope, $routeParams, $timeout, $location, UserService, Plan
|
||||||
|
|
||||||
// Load the list of plans.
|
// Load the list of plans.
|
||||||
PlanService.getPlans(function(plans) {
|
PlanService.getPlans(function(plans) {
|
||||||
$scope.plans = plans.business;
|
$scope.plans = plans;
|
||||||
$scope.currentPlan = null;
|
$scope.currentPlan = null;
|
||||||
if (requested) {
|
if (requested) {
|
||||||
PlanService.getPlan(requested, function(plan) {
|
PlanService.getPlan(requested, function(plan) {
|
||||||
|
|
|
@ -101,16 +101,16 @@ Email: my@email.com</pre>
|
||||||
as an HTTP <b>POST</b> to the specified URL, with a JSON body describing the push:<br><br>
|
as an HTTP <b>POST</b> to the specified URL, with a JSON body describing the push:<br><br>
|
||||||
<pre>
|
<pre>
|
||||||
{
|
{
|
||||||
<span class="code-info" title="The number of images pushed" bs-tooltip="tooltip.title">"pushed_image_count"</span>: 2,
|
<span class="context-tooltip" title="The number of images pushed" bs-tooltip="tooltip.title">"pushed_image_count"</span>: 2,
|
||||||
<span class="code-info" title="The name of the repository (without its namespace)" bs-tooltip="tooltip.title">"name"</span>: "ubuntu",
|
<span class="context-tooltip" title="The name of the repository (without its namespace)" bs-tooltip="tooltip.title">"name"</span>: "ubuntu",
|
||||||
<span class="code-info" title="The full name of the repository" bs-tooltip="tooltip.title">"repository"</span>:"devtable/ubuntu",
|
<span class="context-tooltip" title="The full name of the repository" bs-tooltip="tooltip.title">"repository"</span>:"devtable/ubuntu",
|
||||||
<span class="code-info" title="The URL at which the repository can be pulled by Docker" bs-tooltip="tooltip.title">"docker_url"</span>: "quay.io/devtable/ubuntu",
|
<span class="context-tooltip" title="The URL at which the repository can be pulled by Docker" bs-tooltip="tooltip.title">"docker_url"</span>: "quay.io/devtable/ubuntu",
|
||||||
<span class="code-info" title="Map of updated tag names to their latest image IDs" bs-tooltip="tooltip.title">"updated_tags"</span>: {
|
<span class="context-tooltip" title="Map of updated tag names to their latest image IDs" bs-tooltip="tooltip.title">"updated_tags"</span>: {
|
||||||
"latest": "b750fe79269d2ec9a3c593ef05b4332b1d1a02a62b4accb2c21d589ff2f5f2dc"
|
"latest": "b750fe79269d2ec9a3c593ef05b4332b1d1a02a62b4accb2c21d589ff2f5f2dc"
|
||||||
},
|
},
|
||||||
<span class="code-info" title="The namespace of the repository" bs-tooltip="tooltip.title">"namespace"</span>: "devtable",
|
<span class="context-tooltip" title="The namespace of the repository" bs-tooltip="tooltip.title">"namespace"</span>: "devtable",
|
||||||
<span class="code-info" title="Whether the repository is public or private" bs-tooltip="tooltip.title">"visibility"</span>: "private",
|
<span class="context-tooltip" title="Whether the repository is public or private" bs-tooltip="tooltip.title">"visibility"</span>: "private",
|
||||||
<span class="code-info" title="The Quay URL for the repository" bs-tooltip="tooltip.title">"homepage"</span>: "https://quay.io/repository/devtable/ubuntu"
|
<span class="context-tooltip" title="The Quay URL for the repository" bs-tooltip="tooltip.title">"homepage"</span>: "https://quay.io/repository/devtable/ubuntu"
|
||||||
}
|
}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -72,7 +72,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="button-bar">
|
<div class="button-bar">
|
||||||
<button class="btn btn-large btn-success" type="submit" ng-disabled="newOrgForm.$invalid || !currentPlan">
|
<button class="btn btn-large btn-success" type="submit" ng-disabled="newOrgForm.$invalid || !currentPlan"
|
||||||
|
analytics-on analytics-event="create_organization">
|
||||||
Create Organization
|
Create Organization
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -74,7 +74,11 @@
|
||||||
<!-- Payment -->
|
<!-- Payment -->
|
||||||
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired && isUserNamespace">
|
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired && isUserNamespace">
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
In order to make this repository private, you’ll need to upgrade your plan to <b>{{ planRequired.title }}</b>. This will cost $<span>{{ planRequired.price / 100 }}</span>/month.
|
In order to make this repository private, you’ll need to upgrade your plan to
|
||||||
|
<b style="border-bottom: 1px dotted black;" bs-tooltip="'<b>' + planRequired.title + '</b><br>' + planRequired.privateRepos + ' private repositories'">
|
||||||
|
{{ planRequired.title }}
|
||||||
|
</b>.
|
||||||
|
This will cost $<span>{{ planRequired.price / 100 }}</span>/month.
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-primary" ng-click="upgradePlan()" ng-show="!planChanging">Upgrade now</a>
|
<a class="btn btn-primary" ng-click="upgradePlan()" ng-show="!planChanging">Upgrade now</a>
|
||||||
<div class="quay-spinner" ng-show="planChanging"></div>
|
<div class="quay-spinner" ng-show="planChanging"></div>
|
||||||
|
|
|
@ -40,60 +40,7 @@
|
||||||
|
|
||||||
<!-- Billing History tab -->
|
<!-- Billing History tab -->
|
||||||
<div id="billing" class="tab-pane">
|
<div id="billing" class="tab-pane">
|
||||||
<div ng-show="invoiceLoading">
|
<div class="billing-invoices" organization="organization" visible="invoicesShown"></div>
|
||||||
<div class="quay-spinner"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ng-show="!invoiceLoading && !invoices">
|
|
||||||
No invoices have been created
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ng-show="!invoiceLoading && invoices">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<th>Billing Date/Time</th>
|
|
||||||
<th>Amount Due</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th></th>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody class="invoice" ng-repeat="invoice in invoices">
|
|
||||||
<tr class="invoice-title">
|
|
||||||
<td ng-click="toggleInvoice(invoice.id)"><span class="invoice-datetime">{{ invoice.date * 1000 | date:'medium' }}</span></td>
|
|
||||||
<td ng-click="toggleInvoice(invoice.id)"><span class="invoice-amount">{{ invoice.amount_due / 100 }}</span></td>
|
|
||||||
<td>
|
|
||||||
<span class="invoice-status">
|
|
||||||
<span class="success" ng-show="invoice.paid">Paid - Thank you!</span>
|
|
||||||
<span class="danger" ng-show="!invoice.paid && invoice.attempted && invoice.closed">Payment failed</span>
|
|
||||||
<span class="danger" ng-show="!invoice.paid && invoice.attempted && !invoice.closed">Payment failed - Will retry soon</span>
|
|
||||||
<span class="pending" ng-show="!invoice.paid && !invoice.attempted">Payment pending</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a ng-show="invoice.paid" href="/receipt?id={{ invoice.id }}" download="receipt.pdf" target="_new">
|
|
||||||
<i class="fa fa-download" title="Download Receipt" bs-tooltip="tooltip.title"></i>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr ng-class="invoiceExpanded[invoice.id] ? 'in' : 'out'" class="invoice-details panel-collapse collapse">
|
|
||||||
<td colspan="3">
|
|
||||||
<dl class="dl-normal">
|
|
||||||
<dt>Billing Period</dt>
|
|
||||||
<dd>
|
|
||||||
<span>{{ invoice.period_start * 1000 | date:'mediumDate' }}</span> -
|
|
||||||
<span>{{ invoice.period_end * 1000 | date:'mediumDate' }}</span>
|
|
||||||
</dd>
|
|
||||||
<dt>Plan</dt>
|
|
||||||
<dd>
|
|
||||||
<span>{{ invoice.plan ? plan_map[invoice.plan].title : '(N/A)' }}</span>
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Members tab -->
|
<!-- Members tab -->
|
||||||
|
|
|
@ -1,44 +1,107 @@
|
||||||
<div class="container plans content-container">
|
<div class="container plans content-container">
|
||||||
<div class="callout">
|
|
||||||
Plans & Pricing
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="all-plans">
|
|
||||||
All plans include <span class="feature">unlimited public repositories</span> and <span class="feature">unlimited sharing</span>. All paid plans have a <span class="feature">14-day free trial</span>.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row plans-list">
|
<div class="row plans-list">
|
||||||
<div class="col-xs-0 col-lg-1"></div>
|
<div class="col-sm-2">
|
||||||
<div class="col-lg-2 col-xs-4 plan-container" ng-repeat="plan in plans.user">
|
<div class="features-bar hidden-xs">
|
||||||
<div class="plan" ng-class="plan.stripeId">
|
<div class="visible-lg" style="height: 50px"></div>
|
||||||
<div class="plan-title">{{ plan.title }}</div>
|
<div class="visible-md visible-sm" style="height: 70px"></div>
|
||||||
<div class="plan-price">${{ plan.price/100 }}</div>
|
|
||||||
<div class="count"><b>{{ plan.privateRepos }}</b> private repositories</div>
|
<div class="feature">
|
||||||
<div class="description">{{ plan.audience }}</div>
|
<span class="feature-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
|
||||||
<div class="smaller">SSL secured connections</div>
|
title="All plans have unlimited public repositories">
|
||||||
<button class="btn btn-primary btn-block" ng-click="buyNow(plan.stripeId)">Sign Up Now</button>
|
<span class="hidden-sm-inline">Public Repositories</span>
|
||||||
</div>
|
<span class="visible-sm-inline">Public Repos</span>
|
||||||
|
</span>
|
||||||
|
<i class="fa fa-hdd visible-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<span class="feature-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
|
||||||
|
title="SSL encryption is enabled end-to-end for all operations">
|
||||||
|
SSL Encryption
|
||||||
|
</span>
|
||||||
|
<i class="fa fa-lock visible-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<span class="feature-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
|
||||||
|
title="Allows users or organizations to grant permissions in multiple repositories to the same non-login-capable account">
|
||||||
|
Robot accounts
|
||||||
|
</span>
|
||||||
|
<i class="fa fa-wrench visible-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<span class="feature-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
|
||||||
|
title="Repository images can be built directly from Dockerfiles">
|
||||||
|
Dockerfile Build
|
||||||
|
</span>
|
||||||
|
<i class="fa fa-upload visible-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<span class="feature-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
|
||||||
|
title="Grant subsets of users in an organization their own permissions, either on a global basis or a per-repository basis">
|
||||||
|
Teams
|
||||||
|
</span>
|
||||||
|
<i class="fa fa-group visible-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<span class="feature-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
|
||||||
|
title="Every action take within an organization is logged in detail, with the ability to visualize logs and download them">
|
||||||
|
Logging
|
||||||
|
</span>
|
||||||
|
<i class="fa fa-bar-chart-o visible-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<span class="feature-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
|
||||||
|
title="Administrators can view and download the full invoice history for their organization">
|
||||||
|
Invoice History
|
||||||
|
</span>
|
||||||
|
<i class="fa fa-calendar visible-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<span class="feature-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
|
||||||
|
title="All plans have a 14-day free trial">
|
||||||
|
<span class="hidden-sm-inline">14-Day Free Trial</span>
|
||||||
|
<span class="visible-sm-inline">14-Day Trial</span>
|
||||||
|
</span>
|
||||||
|
<i class="fa fa-clock-o visible-lg"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col-sm-2 plan-container" ng-repeat="plan in plans" ng-show="plan.price > 0 && !plan.deprecated">
|
||||||
|
<div class="plan" ng-class="plan.stripeId + ' ' + (plan.bus_features ? 'business-plan' : '')">
|
||||||
|
<div class="plan-box">
|
||||||
|
<div class="plan-title">{{ plan.title }}</div>
|
||||||
|
<div class="plan-price">${{ plan.price/100 }}</div>
|
||||||
|
|
||||||
<div class="callout">
|
<div class="description">{{ plan.audience }}</div>
|
||||||
Business Plan Pricing
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="all-plans">
|
<div class="features hidden-xs">
|
||||||
All business plans include all of the personal plan features, plus: <span class="business-feature">organizations</span> and <span class="business-feature">teams</span> with <span class="business-feature">delegated access</span> to the organization. All business plans have a <span class="business-feature">14-day free trial</span>.
|
<div class="count"><b>{{ plan.privateRepos }}</b> private repositories</div>
|
||||||
</div>
|
<div class="feature present"></div>
|
||||||
|
<div class="feature present"></div>
|
||||||
|
<div class="feature present"></div>
|
||||||
|
<div class="feature present"></div>
|
||||||
|
<div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div>
|
||||||
|
<div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div>
|
||||||
|
<div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div>
|
||||||
|
<div class="feature present"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="features visible-xs">
|
||||||
|
<div class="count"><b>{{ plan.privateRepos }}</b> private repositories</div>
|
||||||
|
<div class="feature present">Unlimited Public Repositories</div>
|
||||||
|
<div class="feature present">SSL Encryption</div>
|
||||||
|
<div class="feature present">Robot accounts</div>
|
||||||
|
<div class="feature present">Dockerfile Build</div>
|
||||||
|
<div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Teams</div>
|
||||||
|
<div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Logging</div>
|
||||||
|
<div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Invoice History</div>
|
||||||
|
<div class="feature present">14-Day Free Trial</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-block" ng-class="plan.bus_features ? 'btn-success' : 'btn-primary'"
|
||||||
|
ng-click="buyNow(plan.stripeId)">Start <span class="hidden-sm-inline">Free</span> Trial</button>
|
||||||
|
|
||||||
<div class="row plans-list">
|
|
||||||
<div class="col-xs-0 col-lg-1"></div>
|
|
||||||
<div class="col-lg-2 col-xs-4 plan-container" ng-repeat="plan in plans.business">
|
|
||||||
<div class="plan business-plan" ng-class="plan.stripeId">
|
|
||||||
<div class="plan-title">{{ plan.title }}</div>
|
|
||||||
<div class="plan-price">${{ plan.price/100 }}</div>
|
|
||||||
<div class="count"><b>{{ plan.privateRepos }}</b> private repositories</div>
|
|
||||||
<div class="description">{{ plan.audience }}</div>
|
|
||||||
<div class="smaller">SSL secured connections</div>
|
|
||||||
<button class="btn btn-success btn-block" ng-click="createOrg(plan.stripeId)">Sign Up Now</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -27,16 +27,23 @@
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<ul class="nav nav-pills nav-stacked">
|
<ul class="nav nav-pills nav-stacked">
|
||||||
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#plan">Plan and Usage</a></li>
|
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#plan">Plan and Usage</a></li>
|
||||||
<li ng-show="hasPaidPlan"><a href="javascript:void(0)" data-toggle="tab" data-target="#billing">Billing Options</a></li>
|
<li ng-show="hasPaidPlan"><a href="javascript:void(0)" data-toggle="tab" data-target="#billingoptions">Billing Options</a></li>
|
||||||
|
<li ng-show="hasPaidBusinessPlan"><a href="javascript:void(0)" data-toggle="tab" data-target="#billing" ng-click="loadInvoices()">Billing History</a></li>
|
||||||
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#robots">Robot Accounts</a></li>
|
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#robots">Robot Accounts</a></li>
|
||||||
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#password">Set Password</a></li>
|
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#password">Set Password</a></li>
|
||||||
|
<li ng-show="hasPaidBusinessPlan"><a href="javascript:void(0)" data-toggle="tab" data-target="#logs" ng-click="loadLogs()">Usage Logs</a></li>
|
||||||
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#migrate" id="migrateTab">Convert to Organization</a></li>
|
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#migrate" id="migrateTab">Convert to Organization</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="col-md-10">
|
<div class="col-md-10">
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
|
<!-- Logs tab -->
|
||||||
|
<div id="logs" class="tab-pane">
|
||||||
|
<div class="logs-view" user="user" visible="logsShown"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Plans tab -->
|
<!-- Plans tab -->
|
||||||
<div id="plan" class="tab-pane active">
|
<div id="plan" class="tab-pane active">
|
||||||
<div class="plan-manager" user="user.username" ready-for-plan="readyForPlan()" plan-changed="planChanged(plan)"></div>
|
<div class="plan-manager" user="user.username" ready-for-plan="readyForPlan()" plan-changed="planChanged(plan)"></div>
|
||||||
|
@ -69,10 +76,15 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Billing options tab -->
|
<!-- Billing options tab -->
|
||||||
<div id="billing" class="tab-pane">
|
<div id="billingoptions" class="tab-pane">
|
||||||
<div class="billing-options" user="user"></div>
|
<div class="billing-options" user="user"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Billing History tab -->
|
||||||
|
<div id="billing" class="tab-pane">
|
||||||
|
<div class="billing-invoices" user="user" visible="invoicesShown"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Convert to organization tab -->
|
<!-- Convert to organization tab -->
|
||||||
<div id="migrate" class="tab-pane">
|
<div id="migrate" class="tab-pane">
|
||||||
<!-- Step 0 -->
|
<!-- Step 0 -->
|
||||||
|
@ -86,11 +98,11 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-body" ng-show="user.organizations.length == 0">
|
<div class="panel-body" ng-show="user.organizations.length == 0">
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-warning">
|
||||||
Converting a user account into an organization <b>cannot be undone</b>.<br> Here be many fire-breathing dragons!
|
Note: Converting a user account into an organization <b>cannot be undone</b>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-danger" ng-click="showConvertForm()">Start conversion process</button>
|
<button class="btn btn-primary" ng-click="showConvertForm()">Start conversion process</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -113,7 +125,7 @@
|
||||||
ng-model="org.adminUser" required autofocus>
|
ng-model="org.adminUser" required autofocus>
|
||||||
<input id="adminPassword" name="adminPassword" type="password" class="form-control" placeholder="Admin Password"
|
<input id="adminPassword" name="adminPassword" type="password" class="form-control" placeholder="Admin Password"
|
||||||
ng-model="org.adminPassword" required>
|
ng-model="org.adminPassword" required>
|
||||||
<span class="description">The username and password for an <b>existing account</b> that will become administrator of the organization</span>
|
<span class="description">The username and password for the account that will become administrator of the organization</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Plans Table -->
|
<!-- Plans Table -->
|
||||||
|
@ -123,7 +135,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="button-bar">
|
<div class="button-bar">
|
||||||
<button class="btn btn-large btn-danger" type="submit" ng-disabled="convertForm.$invalid || !org.plan">
|
<button class="btn btn-large btn-danger" type="submit" ng-disabled="convertForm.$invalid || !org.plan"
|
||||||
|
analytics-on analytics-event="convert_to_organization">
|
||||||
Convert To Organization
|
Convert To Organization
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
Reference in a new issue