Merge remote-tracking branch 'origin/orgs'
Conflicts: static/partials/repo-admin.html
13
README.md
|
@ -43,3 +43,16 @@ kill -HUP <pid of gunicorn>
|
|||
kill <pids of worker daemons>
|
||||
restart daemons
|
||||
```
|
||||
|
||||
running the tests:
|
||||
|
||||
```
|
||||
STACK=test python -m unittest discover
|
||||
```
|
||||
|
||||
generating screenshots:
|
||||
|
||||
```
|
||||
cd screenshots
|
||||
casperjs screenshots.js --d
|
||||
```
|
10
app.py
|
@ -7,7 +7,8 @@ from flask.ext.principal import Principal
|
|||
from flask.ext.login import LoginManager
|
||||
from flask.ext.mail import Mail
|
||||
|
||||
from config import ProductionConfig, DebugConfig, LocalHostedConfig
|
||||
from config import (ProductionConfig, DebugConfig, LocalHostedConfig,
|
||||
TestConfig)
|
||||
from util import analytics
|
||||
|
||||
|
||||
|
@ -22,6 +23,9 @@ if stack.startswith('prod'):
|
|||
elif stack.startswith('localhosted'):
|
||||
logger.info('Running with debug config on production data.')
|
||||
config = LocalHostedConfig()
|
||||
elif stack.startswith('test'):
|
||||
logger.info('Running with test config on ephemeral data.')
|
||||
config = TestConfig()
|
||||
else:
|
||||
logger.info('Running with debug config.')
|
||||
config = DebugConfig()
|
||||
|
@ -36,6 +40,6 @@ login_manager.init_app(app)
|
|||
mail = Mail()
|
||||
mail.init_app(app)
|
||||
|
||||
stripe.api_key = app.config['STRIPE_SECRET_KEY']
|
||||
stripe.api_key = app.config.get('STRIPE_SECRET_KEY', None)
|
||||
|
||||
mixpanel = analytics.init_app(app)
|
||||
mixpanel = app.config['ANALYTICS'].init_app(app)
|
||||
|
|
|
@ -14,6 +14,8 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
_ResourceNeed = namedtuple('resource', ['type', 'namespace', 'name', 'role'])
|
||||
_RepositoryNeed = partial(_ResourceNeed, 'repository')
|
||||
_OrganizationNeed = namedtuple('organization', ['orgname', 'role'])
|
||||
_TeamNeed = namedtuple('orgteam', ['orgname', 'teamname', 'role'])
|
||||
|
||||
|
||||
class QuayDeferredPermissionUser(Identity):
|
||||
|
@ -27,13 +29,32 @@ class QuayDeferredPermissionUser(Identity):
|
|||
logger.debug('Loading user permissions after deferring.')
|
||||
user_object = model.get_user(self.id)
|
||||
|
||||
for user in model.get_all_user_permissions(user_object):
|
||||
grant = _RepositoryNeed(user.repositorypermission.repository.namespace,
|
||||
user.repositorypermission.repository.name,
|
||||
user.repositorypermission.role.name)
|
||||
# Add the user specific permissions
|
||||
user_grant = UserNeed(user_object.username)
|
||||
self.provides.add(user_grant)
|
||||
|
||||
# Every user is the admin of their own 'org'
|
||||
user_namespace = _OrganizationNeed(user_object.username, 'admin')
|
||||
self.provides.add(user_namespace)
|
||||
|
||||
# Add repository permissions
|
||||
for perm in model.get_all_user_permissions(user_object):
|
||||
grant = _RepositoryNeed(perm.repository.namespace,
|
||||
perm.repository.name, perm.role.name)
|
||||
logger.debug('User added permission: {0}'.format(grant))
|
||||
self.provides.add(grant)
|
||||
|
||||
# Add namespace permissions derived
|
||||
for team in model.get_org_wide_permissions(user_object):
|
||||
grant = _OrganizationNeed(team.organization.username, team.role.name)
|
||||
logger.debug('Organization team added permission: {0}'.format(grant))
|
||||
self.provides.add(grant)
|
||||
|
||||
team_grant = _TeamNeed(team.organization.username, team.name,
|
||||
team.role.name)
|
||||
logger.debug('Team added permission: {0}'.format(team_grant))
|
||||
self.provides.add(team_grant)
|
||||
|
||||
self._permissions_loaded = True
|
||||
|
||||
return super(QuayDeferredPermissionUser, self).can(permission)
|
||||
|
@ -43,7 +64,9 @@ class ModifyRepositoryPermission(Permission):
|
|||
def __init__(self, namespace, name):
|
||||
admin_need = _RepositoryNeed(namespace, name, 'admin')
|
||||
write_need = _RepositoryNeed(namespace, name, 'write')
|
||||
super(ModifyRepositoryPermission, self).__init__(admin_need, write_need)
|
||||
org_admin_need = _OrganizationNeed(namespace, 'admin')
|
||||
super(ModifyRepositoryPermission, self).__init__(admin_need, write_need,
|
||||
org_admin_need)
|
||||
|
||||
|
||||
class ReadRepositoryPermission(Permission):
|
||||
|
@ -51,14 +74,25 @@ class ReadRepositoryPermission(Permission):
|
|||
admin_need = _RepositoryNeed(namespace, name, 'admin')
|
||||
write_need = _RepositoryNeed(namespace, name, 'write')
|
||||
read_need = _RepositoryNeed(namespace, name, 'read')
|
||||
org_admin_need = _OrganizationNeed(namespace, 'admin')
|
||||
super(ReadRepositoryPermission, self).__init__(admin_need, write_need,
|
||||
read_need)
|
||||
read_need, org_admin_need)
|
||||
|
||||
|
||||
class AdministerRepositoryPermission(Permission):
|
||||
def __init__(self, namespace, name):
|
||||
admin_need = _RepositoryNeed(namespace, name, 'admin')
|
||||
super(AdministerRepositoryPermission, self).__init__(admin_need)
|
||||
org_admin_need = _OrganizationNeed(namespace, 'admin')
|
||||
super(AdministerRepositoryPermission, self).__init__(admin_need,
|
||||
org_admin_need)
|
||||
|
||||
|
||||
class CreateRepositoryPermission(Permission):
|
||||
def __init__(self, namespace):
|
||||
admin_org = _OrganizationNeed(namespace, 'admin')
|
||||
create_repo_org = _OrganizationNeed(namespace, 'creator')
|
||||
super(CreateRepositoryPermission, self).__init__(admin_org,
|
||||
create_repo_org)
|
||||
|
||||
|
||||
class UserPermission(Permission):
|
||||
|
@ -67,6 +101,31 @@ class UserPermission(Permission):
|
|||
super(UserPermission, self).__init__(user_need)
|
||||
|
||||
|
||||
class AdministerOrganizationPermission(Permission):
|
||||
def __init__(self, org_name):
|
||||
admin_org = _OrganizationNeed(org_name, 'admin')
|
||||
super(AdministerOrganizationPermission, self).__init__(admin_org)
|
||||
|
||||
|
||||
class OrganizationMemberPermission(Permission):
|
||||
def __init__(self, org_name):
|
||||
admin_org = _OrganizationNeed(org_name, 'admin')
|
||||
repo_creator_org = _OrganizationNeed(org_name, 'creator')
|
||||
org_member = _OrganizationNeed(org_name, 'member')
|
||||
super(OrganizationMemberPermission, self).__init__(admin_org, org_member,
|
||||
repo_creator_org)
|
||||
|
||||
|
||||
class ViewTeamPermission(Permission):
|
||||
def __init__(self, org_name, team_name):
|
||||
team_admin = _TeamNeed(org_name, team_name, 'admin')
|
||||
team_creator = _TeamNeed(org_name, team_name, 'creator')
|
||||
team_member = _TeamNeed(org_name, team_name, 'member')
|
||||
admin_org = _OrganizationNeed(org_name, 'admin')
|
||||
super(ViewTeamPermission, self).__init__(team_admin, team_creator,
|
||||
team_member, admin_org)
|
||||
|
||||
|
||||
@identity_loaded.connect_via(app)
|
||||
def on_identity_loaded(sender, identity):
|
||||
logger.debug('Identity loaded: %s' % identity)
|
||||
|
|
59
config.py
|
@ -2,6 +2,13 @@ import logging
|
|||
import sys
|
||||
|
||||
from peewee import MySQLDatabase, SqliteDatabase
|
||||
from storage.s3 import S3Storage
|
||||
from storage.local import LocalStorage
|
||||
from data.userfiles import UserRequestFiles
|
||||
from util import analytics
|
||||
|
||||
from test.teststorage import FakeStorage, FakeUserfiles
|
||||
from test import analytics as fake_analytics
|
||||
|
||||
|
||||
LOG_FORMAT = '%(asctime)-15s - %(levelname)s - %(pathname)s - ' + \
|
||||
|
@ -31,6 +38,12 @@ class SQLiteDB(object):
|
|||
DB_DRIVER = SqliteDatabase
|
||||
|
||||
|
||||
class EphemeralDB(object):
|
||||
DB_NAME = ':memory:'
|
||||
DB_CONNECTION_ARGS = {}
|
||||
DB_DRIVER = SqliteDatabase
|
||||
|
||||
|
||||
class RDSMySQL(object):
|
||||
DB_NAME = 'quay'
|
||||
DB_CONNECTION_ARGS = {
|
||||
|
@ -49,12 +62,27 @@ class AWSCredentials(object):
|
|||
|
||||
|
||||
class S3Storage(AWSCredentials):
|
||||
STORAGE_KIND = 's3'
|
||||
STORAGE = S3Storage('', AWSCredentials.AWS_ACCESS_KEY,
|
||||
AWSCredentials.AWS_SECRET_KEY,
|
||||
AWSCredentials.REGISTRY_S3_BUCKET)
|
||||
|
||||
|
||||
class LocalStorage(object):
|
||||
STORAGE_KIND = 'local'
|
||||
LOCAL_STORAGE_DIR = 'test/data/registry'
|
||||
STORAGE = LocalStorage('test/data/registry')
|
||||
|
||||
|
||||
class FakeStorage(object):
|
||||
STORAGE = FakeStorage()
|
||||
|
||||
|
||||
class FakeUserfiles(object):
|
||||
USERFILES = FakeUserfiles()
|
||||
|
||||
|
||||
class S3Userfiles(AWSCredentials):
|
||||
USERFILES = UserRequestFiles(AWSCredentials.AWS_ACCESS_KEY,
|
||||
AWSCredentials.AWS_SECRET_KEY,
|
||||
AWSCredentials.REGISTRY_S3_BUCKET)
|
||||
|
||||
|
||||
class StripeTestConfig(object):
|
||||
|
@ -67,11 +95,16 @@ class StripeLiveConfig(object):
|
|||
STRIPE_PUBLISHABLE_KEY = 'pk_live_P5wLU0vGdHnZGyKnXlFG4oiu'
|
||||
|
||||
|
||||
class FakeAnalytics(object):
|
||||
ANALYTICS = fake_analytics
|
||||
|
||||
|
||||
class MixpanelTestConfig(object):
|
||||
ANALYTICS = analytics
|
||||
MIXPANEL_KEY = '38014a0f27e7bdc3ff8cc7cc29c869f9'
|
||||
|
||||
|
||||
class MixpanelProdConfig(object):
|
||||
class MixpanelProdConfig(MixpanelTestConfig):
|
||||
MIXPANEL_KEY = '50ff2b2569faa3a51c8f5724922ffb7e'
|
||||
|
||||
|
||||
|
@ -100,9 +133,20 @@ class BuildNodeConfig(object):
|
|||
BUILD_NODE_PULL_TOKEN = 'F02O2E86CQLKZUQ0O81J8XDHQ6F0N1V36L9JTOEEK6GKKMT1GI8PTJQT4OU88Y6G'
|
||||
|
||||
|
||||
class TestConfig(FlaskConfig, FakeStorage, EphemeralDB, FakeUserfiles,
|
||||
FakeAnalytics):
|
||||
LOGGING_CONFIG = {
|
||||
'level': logging.DEBUG,
|
||||
'format': LOG_FORMAT
|
||||
}
|
||||
POPULATE_DB_TEST_DATA = True
|
||||
TESTING = True
|
||||
INCLUDE_TEST_ENDPOINTS = True
|
||||
|
||||
|
||||
class DebugConfig(FlaskConfig, MailConfig, LocalStorage, SQLiteDB,
|
||||
StripeTestConfig, MixpanelTestConfig, GitHubTestConfig,
|
||||
DigitalOceanConfig, AWSCredentials, BuildNodeConfig):
|
||||
DigitalOceanConfig, BuildNodeConfig, S3Userfiles):
|
||||
LOGGING_CONFIG = {
|
||||
'level': logging.DEBUG,
|
||||
'format': LOG_FORMAT
|
||||
|
@ -115,7 +159,7 @@ class DebugConfig(FlaskConfig, MailConfig, LocalStorage, SQLiteDB,
|
|||
class LocalHostedConfig(FlaskConfig, MailConfig, S3Storage, RDSMySQL,
|
||||
StripeLiveConfig, MixpanelTestConfig,
|
||||
GitHubProdConfig, DigitalOceanConfig,
|
||||
BuildNodeConfig):
|
||||
BuildNodeConfig, S3Userfiles):
|
||||
LOGGING_CONFIG = {
|
||||
'level': logging.DEBUG,
|
||||
'format': LOG_FORMAT
|
||||
|
@ -125,7 +169,8 @@ class LocalHostedConfig(FlaskConfig, MailConfig, S3Storage, RDSMySQL,
|
|||
|
||||
class ProductionConfig(FlaskConfig, MailConfig, S3Storage, RDSMySQL,
|
||||
StripeLiveConfig, MixpanelProdConfig,
|
||||
GitHubProdConfig, DigitalOceanConfig, BuildNodeConfig):
|
||||
GitHubProdConfig, DigitalOceanConfig, BuildNodeConfig,
|
||||
S3Userfiles):
|
||||
LOGGING_CONFIG = {
|
||||
'stream': sys.stderr,
|
||||
'level': logging.DEBUG,
|
||||
|
|
|
@ -4,7 +4,6 @@ import logging
|
|||
from random import SystemRandom
|
||||
from datetime import datetime
|
||||
from peewee import *
|
||||
from peewee import create_model_tables
|
||||
|
||||
from app import app
|
||||
|
||||
|
@ -34,6 +33,37 @@ class User(BaseModel):
|
|||
email = CharField(unique=True, index=True)
|
||||
verified = BooleanField(default=False)
|
||||
stripe_id = CharField(index=True, null=True)
|
||||
organization = BooleanField(default=False, index=True)
|
||||
|
||||
|
||||
class TeamRole(BaseModel):
|
||||
name = CharField(index=True)
|
||||
|
||||
|
||||
class Team(BaseModel):
|
||||
name = CharField(index=True)
|
||||
organization = ForeignKeyField(User, index=True)
|
||||
role = ForeignKeyField(TeamRole)
|
||||
description = TextField(default='')
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
indexes = (
|
||||
# A team name must be unique within an organization
|
||||
(('name', 'organization'), True),
|
||||
)
|
||||
|
||||
|
||||
class TeamMember(BaseModel):
|
||||
user = ForeignKeyField(User, index=True)
|
||||
team = ForeignKeyField(Team, index=True)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
indexes = (
|
||||
# A user may belong to a team only once
|
||||
(('user', 'team'), True),
|
||||
)
|
||||
|
||||
|
||||
class LoginService(BaseModel):
|
||||
|
@ -79,13 +109,15 @@ class Role(BaseModel):
|
|||
|
||||
|
||||
class RepositoryPermission(BaseModel):
|
||||
user = ForeignKeyField(User, index=True)
|
||||
team = ForeignKeyField(Team, index=True, null=True)
|
||||
user = ForeignKeyField(User, index=True, null=True)
|
||||
repository = ForeignKeyField(Repository, index=True)
|
||||
role = ForeignKeyField(Role)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
indexes = (
|
||||
(('team', 'repository'), True),
|
||||
(('user', 'repository'), True),
|
||||
)
|
||||
|
||||
|
@ -168,14 +200,7 @@ class QueueItem(BaseModel):
|
|||
processing_expires = DateTimeField(null=True, index=True)
|
||||
|
||||
|
||||
def initialize_db():
|
||||
create_model_tables([User, Repository, Image, AccessToken, Role,
|
||||
RepositoryPermission, Visibility, RepositoryTag,
|
||||
EmailConfirmation, FederatedLogin, LoginService,
|
||||
QueueItem, RepositoryBuild])
|
||||
Role.create(name='admin')
|
||||
Role.create(name='write')
|
||||
Role.create(name='read')
|
||||
Visibility.create(name='public')
|
||||
Visibility.create(name='private')
|
||||
LoginService.create(name='github')
|
||||
all_models = [User, Repository, Image, AccessToken, Role,
|
||||
RepositoryPermission, Visibility, RepositoryTag,
|
||||
EmailConfirmation, FederatedLogin, LoginService, QueueItem,
|
||||
RepositoryBuild, Team, TeamMember, TeamRole]
|
||||
|
|
510
data/model.py
|
@ -22,6 +22,14 @@ class InvalidUsernameException(DataModelException):
|
|||
pass
|
||||
|
||||
|
||||
class InvalidOrganizationException(DataModelException):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidTeamException(DataModelException):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidPasswordException(DataModelException):
|
||||
pass
|
||||
|
||||
|
@ -73,6 +81,147 @@ def create_user(username, password, email):
|
|||
raise DataModelException(ex.message)
|
||||
|
||||
|
||||
def create_organization(name, email, creating_user):
|
||||
try:
|
||||
# Create the org
|
||||
new_org = create_user(name, None, email)
|
||||
new_org.organization = True
|
||||
new_org.save()
|
||||
|
||||
# Create a team for the owners
|
||||
owners_team = create_team('owners', new_org, 'admin')
|
||||
|
||||
# Add the user who created the org to the owners team
|
||||
add_user_to_team(creating_user, owners_team)
|
||||
|
||||
return new_org
|
||||
except InvalidUsernameException:
|
||||
raise InvalidOrganizationException('Invalid organization name: %s' % name)
|
||||
|
||||
|
||||
def convert_user_to_organization(user, admin_user):
|
||||
# Change the user to an organization.
|
||||
user.organization = True
|
||||
|
||||
# disable this account for login.
|
||||
user.password_hash = None
|
||||
user.save()
|
||||
|
||||
# Clear any federated auth pointing to this user
|
||||
FederatedLogin.delete().where(FederatedLogin.user == user).execute()
|
||||
|
||||
# Create a team for the owners
|
||||
owners_team = create_team('owners', user, 'admin')
|
||||
|
||||
# Add the user who will admin the org to the owners team
|
||||
add_user_to_team(admin_user, owners_team)
|
||||
|
||||
return user
|
||||
|
||||
def create_team(name, org, team_role_name, description=''):
|
||||
if not validate_username(name):
|
||||
raise InvalidTeamException('Invalid team name: %s' % name)
|
||||
|
||||
if not org.organization:
|
||||
raise InvalidOrganizationException('User with name %s is not an org.' %
|
||||
org.username)
|
||||
|
||||
team_role = TeamRole.get(TeamRole.name == team_role_name)
|
||||
return Team.create(name=name, organization=org, role=team_role,
|
||||
description=description)
|
||||
|
||||
|
||||
def __get_user_admin_teams(org_name, team_name, username):
|
||||
Org = User.alias()
|
||||
user_teams = Team.select().join(TeamMember).join(User)
|
||||
with_org = user_teams.switch(Team).join(Org,
|
||||
on=(Org.id == Team.organization))
|
||||
with_role = with_org.switch(Team).join(TeamRole)
|
||||
admin_teams = with_role.where(User.username == username,
|
||||
Org.username == org_name,
|
||||
TeamRole.name == 'admin')
|
||||
return admin_teams
|
||||
|
||||
|
||||
def remove_team(org_name, team_name, removed_by_username):
|
||||
joined = Team.select(Team, TeamRole).join(User).switch(Team).join(TeamRole)
|
||||
|
||||
found = list(joined.where(User.organization == True,
|
||||
User.username == org_name,
|
||||
Team.name == team_name))
|
||||
if not found:
|
||||
raise InvalidTeamException('Team \'%s\' is not a team in org \'%s\'' %
|
||||
(team_name, org_name))
|
||||
|
||||
team = found[0]
|
||||
if team.role.name == 'admin':
|
||||
admin_teams = list(__get_user_admin_teams(org_name, team_name,
|
||||
removed_by_username))
|
||||
|
||||
if len(admin_teams) <= 1:
|
||||
# The team we are trying to remove is the only admin team for this user
|
||||
msg = ('Deleting team \'%s\' would remove all admin from user \'%s\'' %
|
||||
(team_name, removed_by_username))
|
||||
raise DataModelException(msg)
|
||||
|
||||
team.delete_instance(recursive=True, delete_nullable=True)
|
||||
|
||||
|
||||
def add_user_to_team(user, team):
|
||||
try:
|
||||
return TeamMember.create(user=user, team=team)
|
||||
except Exception:
|
||||
raise DataModelException('Unable to add user \'%s\' to team: \'%s\'' %
|
||||
(user.username, team.name))
|
||||
|
||||
|
||||
def remove_user_from_team(org_name, team_name, username, removed_by_username):
|
||||
Org = User.alias()
|
||||
joined = TeamMember.select().join(User).switch(TeamMember).join(Team)
|
||||
with_role = joined.join(TeamRole)
|
||||
with_org = with_role.switch(Team).join(Org,
|
||||
on=(Org.id == Team.organization))
|
||||
found = list(with_org.where(User.username == username,
|
||||
Org.username == org_name,
|
||||
Team.name == team_name))
|
||||
|
||||
if not found:
|
||||
raise DataModelException('User %s does not belong to team %s' %
|
||||
(username, team_name))
|
||||
|
||||
if username == removed_by_username:
|
||||
admin_team_query = __get_user_admin_teams(org_name, team_name, username)
|
||||
admin_team_names = {team.name for team in admin_team_query}
|
||||
if team_name in admin_team_names and len(admin_team_names) <= 1:
|
||||
msg = 'User cannot remove themselves from their only admin team.'
|
||||
raise DataModelException(msg)
|
||||
|
||||
user_in_team = found[0]
|
||||
user_in_team.delete_instance()
|
||||
|
||||
|
||||
def get_team_org_role(team):
|
||||
return TeamRole.get(TeamRole.id == team.role.id)
|
||||
|
||||
|
||||
def set_team_org_permission(team, team_role_name, set_by_username):
|
||||
if team.role.name == 'admin' and team_role_name != 'admin':
|
||||
# We need to make sure we're not removing the users only admin role
|
||||
user_admin_teams = __get_user_admin_teams(team.organization.username,
|
||||
team.name, set_by_username)
|
||||
admin_team_set = {admin_team.name for admin_team in user_admin_teams}
|
||||
if team.name in admin_team_set and len(admin_team_set) <= 1:
|
||||
msg = (('Cannot remove admin from team \'%s\' because calling user ' +
|
||||
'would no longer have admin on org \'%s\'') %
|
||||
(team.name, team.organization.username))
|
||||
raise DataModelException(msg)
|
||||
|
||||
new_role = TeamRole.get(TeamRole.name == team_role_name)
|
||||
team.role = new_role
|
||||
team.save()
|
||||
return team
|
||||
|
||||
|
||||
def create_federated_user(username, email, service_name, service_id):
|
||||
new_user = create_user(username, None, email)
|
||||
new_user.verified = True
|
||||
|
@ -142,14 +291,41 @@ def validate_reset_code(code):
|
|||
|
||||
def get_user(username):
|
||||
try:
|
||||
return User.get(User.username == username)
|
||||
return User.get(User.username == username, User.organization == False)
|
||||
except User.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
def get_matching_users(username_prefix):
|
||||
query = User.select().where(User.username ** (username_prefix + '%'))
|
||||
return list(query.limit(10))
|
||||
def get_matching_teams(team_prefix, organization):
|
||||
query = Team.select().where(Team.name ** (team_prefix + '%'),
|
||||
Team.organization == organization)
|
||||
return query.limit(10)
|
||||
|
||||
|
||||
def get_matching_users(username_prefix, organization=None):
|
||||
Org = User.alias()
|
||||
users_no_orgs = (User.username ** (username_prefix + '%') &
|
||||
(User.organization == False))
|
||||
query = User.select(User.username, Org.username).where(users_no_orgs)
|
||||
|
||||
if organization:
|
||||
with_team = query.join(TeamMember, JOIN_LEFT_OUTER).join(Team,
|
||||
JOIN_LEFT_OUTER)
|
||||
with_org = with_team.join(Org, JOIN_LEFT_OUTER,
|
||||
on=(Org.id == Team.organization))
|
||||
query = with_org.where((Org.id == organization) | (Org.id >> None))
|
||||
|
||||
|
||||
class MatchingUserResult(object):
|
||||
def __init__(self, *args):
|
||||
self.username = args[0]
|
||||
if organization:
|
||||
self.is_org_member = (args[1] == organization.username)
|
||||
else:
|
||||
self.is_org_member = None
|
||||
|
||||
|
||||
return (MatchingUserResult(*args) for args in query.tuples().limit(10))
|
||||
|
||||
|
||||
def verify_user(username, password):
|
||||
|
@ -167,33 +343,121 @@ def verify_user(username, password):
|
|||
return None
|
||||
|
||||
|
||||
def get_user_organizations(username):
|
||||
UserAlias = User.alias()
|
||||
all_teams = User.select().distinct().join(Team).join(TeamMember)
|
||||
with_user = all_teams.join(UserAlias, on=(UserAlias.id == TeamMember.user))
|
||||
return with_user.where(User.organization == True,
|
||||
UserAlias.username == username)
|
||||
|
||||
|
||||
def get_organization(name):
|
||||
try:
|
||||
return User.get(username=name, organization=True)
|
||||
except User.DoesNotExist:
|
||||
raise InvalidOrganizationException('Organization does not exist: %s' %
|
||||
name)
|
||||
|
||||
|
||||
def get_organization_team(orgname, teamname):
|
||||
joined = Team.select().join(User)
|
||||
query = joined.where(Team.name == teamname, User.organization == True,
|
||||
User.username == orgname).limit(1)
|
||||
result = list(query)
|
||||
if not result:
|
||||
raise InvalidTeamException('Team does not exist: %s/%s', orgname,
|
||||
teamname)
|
||||
|
||||
return result[0]
|
||||
|
||||
|
||||
def get_organization_members_with_teams(organization):
|
||||
joined = TeamMember.select().annotate(Team).annotate(User)
|
||||
query = joined.where(Team.organization == organization)
|
||||
return query
|
||||
|
||||
|
||||
def get_organization_team_members(teamid):
|
||||
joined = User.select().join(TeamMember).join(Team)
|
||||
query = joined.where(Team.id == teamid)
|
||||
return query
|
||||
|
||||
|
||||
def get_organization_member_set(orgname):
|
||||
Org = User.alias()
|
||||
user_teams = User.select(User.username).join(TeamMember).join(Team)
|
||||
with_org = user_teams.join(Org, on=(Org.username == orgname))
|
||||
return {user.username for user in with_org}
|
||||
|
||||
|
||||
def get_teams_within_org(organization):
|
||||
return Team.select().where(Team.organization == organization)
|
||||
|
||||
|
||||
def get_user_teams_within_org(username, organization):
|
||||
joined = Team.select().join(TeamMember).join(User)
|
||||
return joined.where(Team.organization == organization,
|
||||
User.username == username)
|
||||
|
||||
|
||||
def get_visible_repositories(username=None, include_public=True, limit=None,
|
||||
sort=False):
|
||||
sort=False, namespace=None):
|
||||
if not username and not include_public:
|
||||
return []
|
||||
|
||||
query = Repository.select().distinct().join(Visibility)
|
||||
or_clauses = []
|
||||
if include_public:
|
||||
or_clauses.append((Visibility.name == 'public'))
|
||||
query = (Repository
|
||||
.select(Repository, Visibility)
|
||||
.distinct()
|
||||
.join(Visibility)
|
||||
.switch(Repository)
|
||||
.join(RepositoryPermission, JOIN_LEFT_OUTER))
|
||||
|
||||
where_clause = None
|
||||
admin_query = None
|
||||
if username:
|
||||
with_perms = query.switch(Repository).join(RepositoryPermission,
|
||||
JOIN_LEFT_OUTER)
|
||||
query = with_perms.join(User)
|
||||
or_clauses.append(User.username == username)
|
||||
UserThroughTeam = User.alias()
|
||||
Org = User.alias()
|
||||
AdminTeam = Team.alias()
|
||||
AdminTeamMember = TeamMember.alias()
|
||||
AdminUser = User.alias()
|
||||
|
||||
if sort:
|
||||
with_images = query.switch(Repository).join(Image, JOIN_LEFT_OUTER)
|
||||
query = with_images.order_by(Image.created.desc())
|
||||
query = (query
|
||||
.join(User, JOIN_LEFT_OUTER)
|
||||
.switch(RepositoryPermission)
|
||||
.join(Team, JOIN_LEFT_OUTER)
|
||||
.join(TeamMember, JOIN_LEFT_OUTER)
|
||||
.join(UserThroughTeam, JOIN_LEFT_OUTER, on=(UserThroughTeam.id ==
|
||||
TeamMember.user))
|
||||
.switch(Repository)
|
||||
.join(Org, JOIN_LEFT_OUTER, on=(Org.username == Repository.namespace))
|
||||
.join(AdminTeam, JOIN_LEFT_OUTER, on=(Org.id ==
|
||||
AdminTeam.organization))
|
||||
.join(TeamRole, JOIN_LEFT_OUTER, on=(AdminTeam.role == TeamRole.id))
|
||||
.switch(AdminTeam)
|
||||
.join(AdminTeamMember, JOIN_LEFT_OUTER, on=(AdminTeam.id ==
|
||||
AdminTeamMember.team))
|
||||
.join(AdminUser, JOIN_LEFT_OUTER, on=(AdminTeamMember.user ==
|
||||
AdminUser.id)))
|
||||
|
||||
if (or_clauses):
|
||||
query = query.where(reduce(operator.or_, or_clauses))
|
||||
where_clause = ((User.username == username) |
|
||||
(UserThroughTeam.username == username) |
|
||||
((AdminUser.username == username) &
|
||||
(TeamRole.name == 'admin')))
|
||||
|
||||
if namespace:
|
||||
where_clause = where_clause & (Repository.namespace == namespace)
|
||||
|
||||
if include_public:
|
||||
new_clause = (Visibility.name == 'public')
|
||||
if where_clause:
|
||||
where_clause = where_clause | new_clause
|
||||
else:
|
||||
where_clause = new_clause
|
||||
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
query.limit(limit)
|
||||
|
||||
return query
|
||||
return query.where(where_clause)
|
||||
|
||||
|
||||
def get_matching_repositories(repo_term, username=None):
|
||||
|
@ -234,10 +498,41 @@ def update_email(user, new_email):
|
|||
|
||||
|
||||
def get_all_user_permissions(user):
|
||||
select = User.select(User, Repository, RepositoryPermission, Role)
|
||||
with_repo = select.join(RepositoryPermission).join(Repository)
|
||||
with_role = with_repo.switch(RepositoryPermission).join(Role)
|
||||
return with_role.where(User.username == user.username)
|
||||
select = RepositoryPermission.select(RepositoryPermission, Role, Repository)
|
||||
with_role = select.join(Role)
|
||||
with_repo = with_role.switch(RepositoryPermission).join(Repository)
|
||||
through_user = with_repo.switch(RepositoryPermission).join(User,
|
||||
JOIN_LEFT_OUTER)
|
||||
as_perm = through_user.switch(RepositoryPermission)
|
||||
through_team = as_perm.join(Team, JOIN_LEFT_OUTER).join(TeamMember,
|
||||
JOIN_LEFT_OUTER)
|
||||
|
||||
UserThroughTeam = User.alias()
|
||||
with_team_member = through_team.join(UserThroughTeam, JOIN_LEFT_OUTER,
|
||||
on=(UserThroughTeam.id ==
|
||||
TeamMember.user))
|
||||
|
||||
return with_team_member.where((User.id == user) |
|
||||
(UserThroughTeam.id == user))
|
||||
|
||||
|
||||
def get_org_wide_permissions(user):
|
||||
Org = User.alias()
|
||||
team_with_role = Team.select(Team, Org, TeamRole).join(TeamRole)
|
||||
with_org = team_with_role.switch(Team).join(Org, on=(Team.organization ==
|
||||
Org.id))
|
||||
with_user = with_org.switch(Team).join(TeamMember).join(User)
|
||||
return with_user.where(User.id == user, Org.organization == True)
|
||||
|
||||
|
||||
def get_all_repo_teams(namespace_name, repository_name):
|
||||
select = RepositoryPermission.select(Team.name.alias('team_name'),
|
||||
Role.name, RepositoryPermission)
|
||||
with_team = select.join(Team)
|
||||
with_role = with_team.switch(RepositoryPermission).join(Role)
|
||||
with_repo = with_role.switch(RepositoryPermission).join(Repository)
|
||||
return with_repo.where(Repository.namespace == namespace_name,
|
||||
Repository.name == repository_name)
|
||||
|
||||
|
||||
def get_all_repo_users(namespace_name, repository_name):
|
||||
|
@ -292,8 +587,10 @@ def create_repository(namespace, name, owner, visibility='private'):
|
|||
repo = Repository.create(namespace=namespace, name=name,
|
||||
visibility=private)
|
||||
admin = Role.get(name='admin')
|
||||
permission = RepositoryPermission.create(user=owner, repository=repo,
|
||||
role=admin)
|
||||
|
||||
if owner and not owner.organization:
|
||||
permission = RepositoryPermission.create(user=owner, repository=repo,
|
||||
role=admin)
|
||||
return repo
|
||||
|
||||
|
||||
|
@ -337,6 +634,7 @@ def get_repository_images(namespace_name, repository_name):
|
|||
return joined.where(Repository.name == repository_name,
|
||||
Repository.namespace == namespace_name)
|
||||
|
||||
|
||||
def list_repository_tags(namespace_name, repository_name):
|
||||
select = RepositoryTag.select(RepositoryTag, Image)
|
||||
with_repo = select.join(Repository)
|
||||
|
@ -386,9 +684,18 @@ def get_parent_images(image_obj):
|
|||
|
||||
def create_or_update_tag(namespace_name, repository_name, tag_name,
|
||||
tag_docker_image_id):
|
||||
repo = Repository.get(Repository.name == repository_name,
|
||||
Repository.namespace == namespace_name)
|
||||
image = Image.get(Image.docker_image_id == tag_docker_image_id)
|
||||
try:
|
||||
repo = Repository.get(Repository.name == repository_name,
|
||||
Repository.namespace == namespace_name)
|
||||
except Repository.DoesNotExist:
|
||||
raise DataModelException('Invalid repository %s/%s' %
|
||||
(namespace_name, repository_name))
|
||||
|
||||
try:
|
||||
image = Image.get(Image.docker_image_id == tag_docker_image_id)
|
||||
except Image.DoesNotExist:
|
||||
raise DataModelException('Invalid image with id: %s' %
|
||||
tag_docker_image_id)
|
||||
|
||||
try:
|
||||
tag = RepositoryTag.get(RepositoryTag.repository == repo,
|
||||
|
@ -402,78 +709,127 @@ def create_or_update_tag(namespace_name, repository_name, tag_name,
|
|||
|
||||
|
||||
def delete_tag(namespace_name, repository_name, tag_name):
|
||||
repo = Repository.get(Repository.name == repository_name,
|
||||
Repository.namespace == namespace_name)
|
||||
tag = RepositoryTag.get(RepositoryTag.repository == repo,
|
||||
RepositoryTag.name == tag_name)
|
||||
tag.delete_instance()
|
||||
joined = RepositoryTag.select().join(Repository)
|
||||
found = list(joined.where(Repository.name == repository_name,
|
||||
Repository.namespace == namespace_name,
|
||||
RepositoryTag.name == tag_name))
|
||||
|
||||
if not found:
|
||||
msg = ('Invalid repository tag \'%s\' on repository \'%s/%s\'' %
|
||||
(tag_name, namespace_name, repository_name))
|
||||
raise DataModelException(msg)
|
||||
|
||||
found[0].delete_instance()
|
||||
|
||||
|
||||
def delete_all_repository_tags(namespace_name, repository_name):
|
||||
repo = Repository.get(Repository.name == repository_name,
|
||||
Repository.namespace == namespace_name)
|
||||
RepositoryTag.delete().where(RepositoryTag.repository == repo)
|
||||
try:
|
||||
repo = Repository.get(Repository.name == repository_name,
|
||||
Repository.namespace == namespace_name)
|
||||
except Repository.DoesNotExist:
|
||||
raise DataModelException('Invalid repository \'%s/%s\'' %
|
||||
(namespace_name, repository_name))
|
||||
RepositoryTag.delete().where(RepositoryTag.repository == repo).execute()
|
||||
|
||||
|
||||
def get_user_repo_permissions(user, repository):
|
||||
select = RepositoryPermission.select()
|
||||
return select.where(RepositoryPermission.user == user,
|
||||
RepositoryPermission.repository == repository)
|
||||
|
||||
|
||||
def user_permission_repo_query(username, namespace_name, repository_name):
|
||||
selected = RepositoryPermission.select(User, Repository, Role,
|
||||
def __entity_permission_repo_query(entity_id, entity_table,
|
||||
entity_id_property, namespace_name,
|
||||
repository_name):
|
||||
""" This method works for both users and teams. """
|
||||
selected = RepositoryPermission.select(entity_table, Repository, Role,
|
||||
RepositoryPermission)
|
||||
with_user = selected.join(User)
|
||||
with_user = selected.join(entity_table)
|
||||
with_role = with_user.switch(RepositoryPermission).join(Role)
|
||||
with_repo = with_role.switch(RepositoryPermission).join(Repository)
|
||||
return with_repo.where(Repository.name == repository_name,
|
||||
Repository.namespace == namespace_name,
|
||||
User.username == username)
|
||||
entity_id_property == entity_id)
|
||||
|
||||
|
||||
def get_user_reponame_permission(username, namespace_name, repository_name):
|
||||
fetched = list(user_permission_repo_query(username, namespace_name,
|
||||
repository_name))
|
||||
fetched = list(__entity_permission_repo_query(username, User, User.username,
|
||||
namespace_name,
|
||||
repository_name))
|
||||
if not fetched:
|
||||
raise DataModelException('User does not have permission for repo.')
|
||||
|
||||
return fetched[0]
|
||||
|
||||
|
||||
def get_team_reponame_permission(team_name, namespace_name, repository_name):
|
||||
fetched = list(__entity_permission_repo_query(team_name, Team, Team.name,
|
||||
namespace_name,
|
||||
repository_name))
|
||||
if not fetched:
|
||||
raise DataModelException('Team does not have permission for repo.')
|
||||
|
||||
return fetched[0]
|
||||
|
||||
|
||||
def delete_user_permission(username, namespace_name, repository_name):
|
||||
if username == namespace_name:
|
||||
raise DataModelException('Namespace owner must always be admin.')
|
||||
|
||||
fetched = list(__entity_permission_repo_query(username, User, User.username,
|
||||
namespace_name,
|
||||
repository_name))
|
||||
if not fetched:
|
||||
raise DataModelException('User does not have permission for repo.')
|
||||
|
||||
fetched[0].delete_instance()
|
||||
|
||||
|
||||
def delete_team_permission(team_name, namespace_name, repository_name):
|
||||
fetched = list(__entity_permission_repo_query(team_name, Team, Team.name,
|
||||
namespace_name,
|
||||
repository_name))
|
||||
if not fetched:
|
||||
raise DataModelException('Team does not have permission for repo.')
|
||||
|
||||
fetched[0].delete_instance()
|
||||
|
||||
|
||||
def __set_entity_repo_permission(entity, permission_entity_property,
|
||||
namespace_name, repository_name, role_name):
|
||||
repo = Repository.get(Repository.name == repository_name,
|
||||
Repository.namespace == namespace_name)
|
||||
new_role = Role.get(Role.name == role_name)
|
||||
|
||||
# Fetch any existing permission for this entity on the repo
|
||||
try:
|
||||
entity_attr = getattr(RepositoryPermission, permission_entity_property)
|
||||
perm = RepositoryPermission.get(entity_attr == entity,
|
||||
RepositoryPermission.repository == repo)
|
||||
perm.role = new_role
|
||||
perm.save()
|
||||
return perm
|
||||
except RepositoryPermission.DoesNotExist:
|
||||
set_entity_kwargs = {permission_entity_property: entity}
|
||||
new_perm = RepositoryPermission.create(repository=repo, role=new_role,
|
||||
**set_entity_kwargs)
|
||||
return new_perm
|
||||
|
||||
|
||||
def set_user_repo_permission(username, namespace_name, repository_name,
|
||||
role_name):
|
||||
if username == namespace_name:
|
||||
raise DataModelException('Namespace owner must always be admin.')
|
||||
|
||||
user = User.get(User.username == username)
|
||||
repo = Repository.get(Repository.name == repository_name,
|
||||
Repository.namespace == namespace_name)
|
||||
new_role = Role.get(Role.name == role_name)
|
||||
|
||||
# Fetch any existing permission for this user on the repo
|
||||
try:
|
||||
perm = RepositoryPermission.get(RepositoryPermission.user == user,
|
||||
RepositoryPermission.repository == repo)
|
||||
perm.role = new_role
|
||||
perm.save()
|
||||
return perm
|
||||
except RepositoryPermission.DoesNotExist:
|
||||
new_perm = RepositoryPermission.create(repository=repo, user=user,
|
||||
role=new_role)
|
||||
return new_perm
|
||||
return __set_entity_repo_permission(user, 'user', namespace_name,
|
||||
repository_name, role_name)
|
||||
|
||||
|
||||
def delete_user_permission(username, namespace_name, repository_name):
|
||||
if username == namespace_name:
|
||||
raise DataModelException('Namespace owner must always be admin.')
|
||||
def set_team_repo_permission(team_name, namespace_name, repository_name,
|
||||
role_name):
|
||||
team = list(Team.select().join(User).where(Team.name == team_name,
|
||||
User.username == namespace_name))
|
||||
if not team:
|
||||
raise DataModelException('No team \'%s\' in organization \'%s\'.' %
|
||||
(team_name, namespace_name))
|
||||
|
||||
fetched = list(user_permission_repo_query(username, namespace_name,
|
||||
repository_name))
|
||||
if not fetched:
|
||||
raise DataModelException('User does not have permission for repo.')
|
||||
|
||||
fetched[0].delete_instance()
|
||||
return __set_entity_repo_permission(team[0], 'team', namespace_name,
|
||||
repository_name, role_name)
|
||||
|
||||
|
||||
def purge_repository(namespace_name, repository_name):
|
||||
|
@ -554,14 +910,6 @@ def load_token_data(code):
|
|||
raise InvalidTokenException('Invalid delegate token code: %s' % code)
|
||||
|
||||
|
||||
def get_repository_build(request_dbid):
|
||||
try:
|
||||
return RepositoryBuild.get(RepositoryBuild.id == request_dbid)
|
||||
except RepositoryBuild.DoesNotExist:
|
||||
msg = 'Unable to locate a build by id: %s' % request_dbid
|
||||
raise InvalidRepositoryBuildException(msg)
|
||||
|
||||
|
||||
def list_repository_builds(namespace_name, repository_name,
|
||||
include_inactive=True):
|
||||
joined = RepositoryBuild.select().join(Repository)
|
||||
|
|
87
data/plans.py
Normal file
|
@ -0,0 +1,87 @@
|
|||
import json
|
||||
import itertools
|
||||
|
||||
USER_PLANS = [
|
||||
{
|
||||
'title': 'Open Source',
|
||||
'price': 0,
|
||||
'privateRepos': 0,
|
||||
'stripeId': 'free',
|
||||
'audience': 'Share with the world',
|
||||
},
|
||||
{
|
||||
'title': 'Micro',
|
||||
'price': 700,
|
||||
'privateRepos': 5,
|
||||
'stripeId': 'micro',
|
||||
'audience': 'For smaller teams',
|
||||
},
|
||||
{
|
||||
'title': 'Basic',
|
||||
'price': 1200,
|
||||
'privateRepos': 10,
|
||||
'stripeId': 'small',
|
||||
'audience': 'For your basic team',
|
||||
},
|
||||
{
|
||||
'title': 'Medium',
|
||||
'price': 2200,
|
||||
'privateRepos': 20,
|
||||
'stripeId': 'medium',
|
||||
'audience': 'For medium teams',
|
||||
},
|
||||
{
|
||||
'title': 'Large',
|
||||
'price': 5000,
|
||||
'privateRepos': 50,
|
||||
'stripeId': 'large',
|
||||
'audience': 'For larger teams',
|
||||
},
|
||||
]
|
||||
|
||||
BUSINESS_PLANS = [
|
||||
{
|
||||
'title': 'Open Source',
|
||||
'price': 0,
|
||||
'privateRepos': 0,
|
||||
'stripeId': 'bus-free',
|
||||
'audience': 'Committment to FOSS',
|
||||
},
|
||||
{
|
||||
'title': 'Skiff',
|
||||
'price': 2500,
|
||||
'privateRepos': 10,
|
||||
'stripeId': 'bus-micro',
|
||||
'audience': 'For startups',
|
||||
},
|
||||
{
|
||||
'title': 'Yacht',
|
||||
'price': 5000,
|
||||
'privateRepos': 20,
|
||||
'stripeId': 'bus-small',
|
||||
'audience': 'For small businesses',
|
||||
},
|
||||
{
|
||||
'title': 'Freighter',
|
||||
'price': 10000,
|
||||
'privateRepos': 50,
|
||||
'stripeId': 'bus-medium',
|
||||
'audience': 'For normal businesses',
|
||||
},
|
||||
{
|
||||
'title': 'Tanker',
|
||||
'price': 20000,
|
||||
'privateRepos': 125,
|
||||
'stripeId': 'bus-large',
|
||||
'audience': 'For large businesses',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_plan(id):
|
||||
""" Returns the plan with the given ID or None if none. """
|
||||
for plan in itertools.chain(USER_PLANS, BUSINESS_PLANS):
|
||||
if plan['stripeId'] == id:
|
||||
return plan
|
||||
|
||||
return None
|
661
endpoints/api.py
|
@ -5,30 +5,33 @@ import requests
|
|||
import urlparse
|
||||
import json
|
||||
|
||||
from flask import request, make_response, jsonify, abort, url_for
|
||||
from flask import request, make_response, jsonify, abort
|
||||
from flask.ext.login import login_required, current_user, logout_user
|
||||
from flask.ext.principal import identity_changed, AnonymousIdentity
|
||||
from functools import wraps
|
||||
from collections import defaultdict
|
||||
|
||||
import storage
|
||||
|
||||
from data import model
|
||||
from data.userfiles import UserRequestFiles
|
||||
from data.queue import dockerfile_build_queue
|
||||
from data.plans import USER_PLANS, BUSINESS_PLANS, get_plan
|
||||
from app import app
|
||||
from util.email import send_confirmation_email, send_recovery_email
|
||||
from util.names import parse_repository_name
|
||||
from util.gravatar import compute_hash
|
||||
from auth.permissions import (ReadRepositoryPermission,
|
||||
ModifyRepositoryPermission,
|
||||
AdministerRepositoryPermission)
|
||||
AdministerRepositoryPermission,
|
||||
CreateRepositoryPermission,
|
||||
AdministerOrganizationPermission,
|
||||
OrganizationMemberPermission,
|
||||
ViewTeamPermission)
|
||||
from endpoints import registry
|
||||
from endpoints.web import common_login
|
||||
from util.cache import cache_control
|
||||
|
||||
|
||||
store = storage.load()
|
||||
store = app.config['STORAGE']
|
||||
user_files = app.config['USERFILES']
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
@ -46,17 +49,41 @@ def handle_dme(ex):
|
|||
return make_response(ex.message, 400)
|
||||
|
||||
|
||||
@app.errorhandler(KeyError)
|
||||
def handle_dme(ex):
|
||||
return make_response(ex.message, 400)
|
||||
|
||||
|
||||
@app.route('/api/')
|
||||
def welcome():
|
||||
return make_response('welcome', 200)
|
||||
|
||||
|
||||
@app.route('/api/plans/')
|
||||
def plans_list():
|
||||
return jsonify({
|
||||
'user': USER_PLANS,
|
||||
'business': BUSINESS_PLANS,
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/user/', methods=['GET'])
|
||||
def get_logged_in_user():
|
||||
def org_view(o):
|
||||
admin_org = AdministerOrganizationPermission(o.username)
|
||||
return {
|
||||
'name': o.username,
|
||||
'gravatar': compute_hash(o.email),
|
||||
'is_org_admin': admin_org.can(),
|
||||
'can_create_repo': admin_org.can() or CreateRepositoryPermission(o.username).can()
|
||||
}
|
||||
|
||||
if current_user.is_anonymous():
|
||||
return jsonify({'anonymous': True})
|
||||
|
||||
user = current_user.db_user()
|
||||
organizations = model.get_user_organizations(user.username)
|
||||
|
||||
return jsonify({
|
||||
'verified': user.verified,
|
||||
'anonymous': False,
|
||||
|
@ -64,8 +91,47 @@ def get_logged_in_user():
|
|||
'email': user.email,
|
||||
'gravatar': compute_hash(user.email),
|
||||
'askForPassword': user.password_hash is None,
|
||||
'organizations': [org_view(o) for o in organizations],
|
||||
'can_create_repo': True
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/user/convert', methods=['POST'])
|
||||
@api_login_required
|
||||
def convert_user_to_organization():
|
||||
user = current_user.db_user()
|
||||
convert_data = request.get_json()
|
||||
|
||||
# Ensure that the new admin user is the not user being converted.
|
||||
admin_username = convert_data['adminUser']
|
||||
if admin_username == user.username:
|
||||
error_resp = jsonify({
|
||||
'reason': 'invaliduser'
|
||||
})
|
||||
error_resp.status_code = 400
|
||||
return error_resp
|
||||
|
||||
# Ensure that the sign in credentials work.
|
||||
admin_password = convert_data['adminPassword']
|
||||
if not model.verify_user(admin_username, admin_password):
|
||||
error_resp = jsonify({
|
||||
'reason': 'invaliduser'
|
||||
})
|
||||
error_resp.status_code = 400
|
||||
return error_resp
|
||||
|
||||
# Subscribe the organization to the new plan.
|
||||
plan = convert_data['plan']
|
||||
subscribe(user, plan, None, BUSINESS_PLANS)
|
||||
|
||||
# Convert the user to an organization.
|
||||
model.convert_user_to_organization(user, model.get_user(admin_username))
|
||||
|
||||
# And finally login with the admin credentials.
|
||||
return conduct_signin(admin_username, admin_password)
|
||||
|
||||
|
||||
|
||||
@app.route('/api/user/', methods=['PUT'])
|
||||
@api_login_required
|
||||
def change_user_details():
|
||||
|
@ -74,7 +140,7 @@ def change_user_details():
|
|||
user_data = request.get_json();
|
||||
|
||||
try:
|
||||
if user_data['password']:
|
||||
if 'password' in user_data:
|
||||
logger.debug('Changing password for user: %s', user.username)
|
||||
model.change_password(user, user_data['password'])
|
||||
except model.InvalidPasswordException, ex:
|
||||
|
@ -93,9 +159,11 @@ def change_user_details():
|
|||
'askForPassword': user.password_hash is None,
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/user/', methods=['POST'])
|
||||
def create_user_api():
|
||||
user_data = request.get_json()
|
||||
|
||||
existing_user = model.get_user(user_data['username'])
|
||||
if existing_user:
|
||||
error_resp = jsonify({
|
||||
|
@ -125,6 +193,10 @@ def signin_api():
|
|||
username = signin_data['username']
|
||||
password = signin_data['password']
|
||||
|
||||
return conduct_signin(username, password)
|
||||
|
||||
|
||||
def conduct_signin(username, password):
|
||||
#TODO Allow email login
|
||||
needs_email_verification = False
|
||||
invalid_credentials = False
|
||||
|
@ -175,34 +247,320 @@ def get_matching_users(prefix):
|
|||
})
|
||||
|
||||
|
||||
user_files = UserRequestFiles(app.config['AWS_ACCESS_KEY'],
|
||||
app.config['AWS_SECRET_KEY'],
|
||||
app.config['REGISTRY_S3_BUCKET'])
|
||||
@app.route('/api/entities/<prefix>', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_matching_entities(prefix):
|
||||
teams = []
|
||||
|
||||
organization_name = request.args.get('organization', None)
|
||||
organization = None
|
||||
if organization_name:
|
||||
permission = OrganizationMemberPermission(organization_name)
|
||||
if permission.can():
|
||||
try:
|
||||
organization = model.get_organization(organization_name)
|
||||
except:
|
||||
pass
|
||||
|
||||
if organization:
|
||||
teams = model.get_matching_teams(prefix, organization)
|
||||
|
||||
users = model.get_matching_users(prefix, organization)
|
||||
|
||||
def team_view(team):
|
||||
result = {
|
||||
'name': team.name,
|
||||
'kind': 'team',
|
||||
'is_org_member': True
|
||||
}
|
||||
return result
|
||||
|
||||
def user_view(user):
|
||||
user_json = {
|
||||
'name': user.username,
|
||||
'kind': 'user',
|
||||
}
|
||||
|
||||
if user.is_org_member is not None:
|
||||
user_json['is_org_member'] = user.is_org_member
|
||||
|
||||
return user_json
|
||||
|
||||
team_data = [team_view(team) for team in teams]
|
||||
user_data = [user_view(user) for user in users]
|
||||
return jsonify({
|
||||
'results': team_data + user_data
|
||||
})
|
||||
|
||||
|
||||
def team_view(orgname, t):
|
||||
view_permission = ViewTeamPermission(orgname, t.name)
|
||||
role = model.get_team_org_role(t).name
|
||||
return {
|
||||
'id': t.id,
|
||||
'name': t.name,
|
||||
'description': t.description,
|
||||
'can_view': view_permission.can(),
|
||||
'role': role
|
||||
}
|
||||
|
||||
|
||||
@app.route('/api/organization/', methods=['POST'])
|
||||
@api_login_required
|
||||
def create_organization_api():
|
||||
org_data = request.get_json()
|
||||
existing = None
|
||||
|
||||
try:
|
||||
existing = model.get_organization(org_data['name']) or model.get_user(org_data['name'])
|
||||
except:
|
||||
pass
|
||||
|
||||
if existing:
|
||||
error_resp = jsonify({
|
||||
'message': 'A user or organization with this name already exists'
|
||||
})
|
||||
error_resp.status_code = 400
|
||||
return error_resp
|
||||
|
||||
try:
|
||||
organization = model.create_organization(org_data['name'], org_data['email'],
|
||||
current_user.db_user())
|
||||
return make_response('Created', 201)
|
||||
except model.DataModelException as ex:
|
||||
error_resp = jsonify({
|
||||
'message': ex.message,
|
||||
})
|
||||
error_resp.status_code = 400
|
||||
return error_resp
|
||||
|
||||
|
||||
@app.route('/api/organization/<orgname>', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_organization(orgname):
|
||||
permission = OrganizationMemberPermission(orgname)
|
||||
if permission.can():
|
||||
user = current_user.db_user()
|
||||
|
||||
def org_view(o, teams):
|
||||
admin_org = AdministerOrganizationPermission(orgname)
|
||||
is_admin = admin_org.can()
|
||||
return {
|
||||
'name': o.username,
|
||||
'gravatar': compute_hash(o.email),
|
||||
'teams': {t.name : team_view(orgname, t) for t in teams},
|
||||
'is_admin': is_admin
|
||||
}
|
||||
|
||||
try:
|
||||
org = model.get_organization(orgname)
|
||||
except model.InvalidOrganizationException:
|
||||
abort(404)
|
||||
|
||||
teams = model.get_teams_within_org(org)
|
||||
return jsonify(org_view(org, teams))
|
||||
|
||||
abort(403)
|
||||
|
||||
@app.route('/api/organization/<orgname>/members', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_organization_members(orgname):
|
||||
permission = AdministerOrganizationPermission(orgname)
|
||||
if permission.can():
|
||||
try:
|
||||
org = model.get_organization(orgname)
|
||||
except model.InvalidOrganizationException:
|
||||
abort(404)
|
||||
|
||||
# Loop to create the members dictionary. Note that the members collection
|
||||
# will return an entry for *every team* a member is on, so we will have
|
||||
# duplicate keys (which is why we pre-build the dictionary).
|
||||
members_dict = {}
|
||||
members = model.get_organization_members_with_teams(org)
|
||||
for member in members:
|
||||
if not member.user.username in members_dict:
|
||||
members_dict[member.user.username] = {'username': member.user.username, 'teams': []}
|
||||
|
||||
members_dict[member.user.username]['teams'].append(member.team.name)
|
||||
|
||||
return jsonify({'members': members_dict})
|
||||
|
||||
abort(403)
|
||||
|
||||
@app.route('/api/organization/<orgname>/private', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_organization_private_allowed(orgname):
|
||||
permission = CreateRepositoryPermission(orgname)
|
||||
if permission.can():
|
||||
organization = model.get_organization(orgname)
|
||||
|
||||
private_repos = model.get_private_repo_count(organization.username)
|
||||
if organization.stripe_id:
|
||||
cus = stripe.Customer.retrieve(organization.stripe_id)
|
||||
if cus.subscription:
|
||||
repos_allowed = get_plan(cus.subscription.plan.id)
|
||||
return jsonify({
|
||||
'privateAllowed': (private_repos < repos_allowed)
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'privateAllowed': False
|
||||
})
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
def member_view(m):
|
||||
return {
|
||||
'username': m.username
|
||||
}
|
||||
|
||||
|
||||
@app.route('/api/organization/<orgname>/team/<teamname>',
|
||||
methods=['PUT', 'POST'])
|
||||
@api_login_required
|
||||
def update_organization_team(orgname, teamname):
|
||||
edit_permission = AdministerOrganizationPermission(orgname)
|
||||
if edit_permission.can():
|
||||
team = None
|
||||
|
||||
json = request.get_json()
|
||||
is_existing = False
|
||||
try:
|
||||
team = model.get_organization_team(orgname, teamname)
|
||||
is_existing = True
|
||||
except:
|
||||
# Create the new team.
|
||||
description = json['description'] if 'description' in json else ''
|
||||
role = json['role'] if 'role' in json else 'member'
|
||||
|
||||
org = model.get_organization(orgname)
|
||||
team = model.create_team(teamname, org, role, description)
|
||||
|
||||
if is_existing:
|
||||
if 'description' in json:
|
||||
team.description = json['description']
|
||||
team.save()
|
||||
if 'role' in json:
|
||||
team = model.set_team_org_permission(team, json['role'],
|
||||
current_user.db_user().username)
|
||||
|
||||
resp = jsonify(team_view(orgname, team))
|
||||
if not is_existing:
|
||||
resp.status_code = 201
|
||||
return resp
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
@app.route('/api/organization/<orgname>/team/<teamname>',
|
||||
methods=['DELETE'])
|
||||
@api_login_required
|
||||
def delete_organization_team(orgname, teamname):
|
||||
permission = AdministerOrganizationPermission(orgname)
|
||||
if permission.can():
|
||||
model.remove_team(orgname, teamname, current_user.db_user().username)
|
||||
return make_response('Deleted', 204)
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
@app.route('/api/organization/<orgname>/team/<teamname>/members',
|
||||
methods=['GET'])
|
||||
@api_login_required
|
||||
def get_organization_team_members(orgname, teamname):
|
||||
view_permission = ViewTeamPermission(orgname, teamname)
|
||||
edit_permission = AdministerOrganizationPermission(orgname)
|
||||
|
||||
if view_permission.can():
|
||||
user = current_user.db_user()
|
||||
team = None
|
||||
|
||||
try:
|
||||
team = model.get_organization_team(orgname, teamname)
|
||||
except:
|
||||
abort(404)
|
||||
|
||||
members = model.get_organization_team_members(team.id)
|
||||
return jsonify({
|
||||
'members': { m.username : member_view(m) for m in members },
|
||||
'can_edit': edit_permission.can()
|
||||
})
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
@app.route('/api/organization/<orgname>/team/<teamname>/members/<membername>',
|
||||
methods=['PUT', 'POST'])
|
||||
@api_login_required
|
||||
def update_organization_team_member(orgname, teamname, membername):
|
||||
permission = AdministerOrganizationPermission(orgname)
|
||||
if permission.can():
|
||||
team = None
|
||||
user = None
|
||||
|
||||
# Find the team.
|
||||
try:
|
||||
team = model.get_organization_team(orgname, teamname)
|
||||
except:
|
||||
abort(404)
|
||||
|
||||
# Find the user.
|
||||
user = model.get_user(membername)
|
||||
if not user:
|
||||
abort(400)
|
||||
|
||||
# Add the user to the team.
|
||||
model.add_user_to_team(user, team)
|
||||
|
||||
return jsonify(member_view(user))
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
@app.route('/api/organization/<orgname>/team/<teamname>/members/<membername>',
|
||||
methods=['DELETE'])
|
||||
@api_login_required
|
||||
def delete_organization_team_member(orgname, teamname, membername):
|
||||
permission = AdministerOrganizationPermission(orgname)
|
||||
if permission.can():
|
||||
# Remote the user from the team.
|
||||
invoking_user = current_user.db_user().username
|
||||
model.remove_user_from_team(orgname, teamname, membername, invoking_user)
|
||||
return make_response('Deleted', 204)
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
@app.route('/api/repository', methods=['POST'])
|
||||
@api_login_required
|
||||
def create_repo_api():
|
||||
owner = current_user.db_user()
|
||||
json = request.get_json()
|
||||
namespace_name = json['namespace'] if 'namespace' in json else owner.username
|
||||
|
||||
namespace_name = owner.username
|
||||
repository_name = request.get_json()['repository']
|
||||
permission = CreateRepositoryPermission(namespace_name)
|
||||
if permission.can():
|
||||
repository_name = json['repository']
|
||||
visibility = json['visibility']
|
||||
|
||||
existing = model.get_repository(namespace_name, repository_name)
|
||||
if existing:
|
||||
return make_response('Repository already exists', 400)
|
||||
existing = model.get_repository(namespace_name, repository_name)
|
||||
if existing:
|
||||
return make_response('Repository already exists', 400)
|
||||
|
||||
visibility = request.get_json()['visibility']
|
||||
visibility = json['visibility']
|
||||
|
||||
repo = model.create_repository(namespace_name, repository_name, owner,
|
||||
visibility)
|
||||
repo.description = request.get_json()['description']
|
||||
repo.save()
|
||||
repo = model.create_repository(namespace_name, repository_name, owner,
|
||||
visibility)
|
||||
repo.description = json['description']
|
||||
repo.save()
|
||||
|
||||
return jsonify({
|
||||
'namespace': namespace_name,
|
||||
'name': repository_name
|
||||
})
|
||||
return jsonify({
|
||||
'namespace': namespace_name,
|
||||
'name': repository_name
|
||||
})
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
@app.route('/api/find/repository', methods=['GET'])
|
||||
|
@ -231,16 +589,15 @@ def match_repos_api():
|
|||
@app.route('/api/repository/', methods=['GET'])
|
||||
def list_repos_api():
|
||||
def repo_view(repo_obj):
|
||||
is_public = model.repository_is_public(repo_obj.namespace, repo_obj.name)
|
||||
|
||||
return {
|
||||
'namespace': repo_obj.namespace,
|
||||
'name': repo_obj.name,
|
||||
'description': repo_obj.description,
|
||||
'is_public': is_public
|
||||
'is_public': repo_obj.visibility.name == 'public',
|
||||
}
|
||||
|
||||
limit = request.args.get('limit', None)
|
||||
namespace_filter = request.args.get('namespace', None)
|
||||
include_public = request.args.get('public', 'true')
|
||||
include_private = request.args.get('private', 'true')
|
||||
sort = request.args.get('sort', 'false')
|
||||
|
@ -260,7 +617,8 @@ def list_repos_api():
|
|||
|
||||
repo_query = model.get_visible_repositories(username, limit=limit,
|
||||
include_public=include_public,
|
||||
sort=sort)
|
||||
sort=sort,
|
||||
namespace=namespace_filter)
|
||||
repos = [repo_view(repo) for repo in repo_query]
|
||||
response = {
|
||||
'repositories': repos
|
||||
|
@ -284,7 +642,7 @@ def update_repo_api(namespace, repository):
|
|||
'success': True
|
||||
})
|
||||
|
||||
abort(404)
|
||||
abort(403)
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>/changevisibility',
|
||||
|
@ -302,7 +660,7 @@ def change_repo_visibility_api(namespace, repository):
|
|||
'success': True
|
||||
})
|
||||
|
||||
abort(404)
|
||||
abort(403)
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>', methods=['DELETE'])
|
||||
|
@ -315,7 +673,7 @@ def delete_repository(namespace, repository):
|
|||
registry.delete_repository_storage(namespace, repository)
|
||||
return make_response('Deleted', 204)
|
||||
|
||||
abort(404)
|
||||
abort(403)
|
||||
|
||||
|
||||
def image_view(image):
|
||||
|
@ -343,6 +701,12 @@ def get_repo_api(namespace, repository):
|
|||
'image': image_view(image),
|
||||
}
|
||||
|
||||
organization = None
|
||||
try:
|
||||
organization = model.get_organization(namespace)
|
||||
except:
|
||||
pass
|
||||
|
||||
permission = ReadRepositoryPermission(namespace, repository)
|
||||
is_public = model.repository_is_public(namespace, repository)
|
||||
if permission.can() or is_public:
|
||||
|
@ -364,9 +728,10 @@ def get_repo_api(namespace, repository):
|
|||
'can_admin': can_admin,
|
||||
'is_public': is_public,
|
||||
'is_building': len(active_builds) > 0,
|
||||
'is_organization': bool(organization)
|
||||
})
|
||||
|
||||
abort(404) # Not fount
|
||||
abort(404) # Not found
|
||||
abort(403) # Permission denied
|
||||
|
||||
|
||||
|
@ -405,6 +770,7 @@ def get_repo_builds(namespace, repository):
|
|||
|
||||
|
||||
@app.route('/api/filedrop/', methods=['POST'])
|
||||
@api_login_required
|
||||
def get_filedrop_url():
|
||||
mime_type = request.get_json()['mimeType']
|
||||
(url, file_id) = user_files.prepare_for_drop(mime_type)
|
||||
|
@ -432,19 +798,26 @@ def request_repo_build(namespace, repository):
|
|||
tag)
|
||||
dockerfile_build_queue.put(json.dumps({'build_id': build_request.id}))
|
||||
|
||||
return jsonify({
|
||||
resp = jsonify({
|
||||
'started': True
|
||||
})
|
||||
resp.status_code = 201
|
||||
return resp
|
||||
|
||||
abort(403) # Permissions denied
|
||||
|
||||
|
||||
def role_view(repo_perm_obj):
|
||||
return {
|
||||
'role': repo_perm_obj.role.name
|
||||
'role': repo_perm_obj.role.name,
|
||||
}
|
||||
|
||||
|
||||
def wrap_role_view_org(role_json, org_member):
|
||||
role_json['is_org_member'] = org_member
|
||||
return role_json
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>/image/', methods=['GET'])
|
||||
@parse_repository_name
|
||||
def list_repository_images(namespace, repository):
|
||||
|
@ -508,7 +881,11 @@ def get_image_changes(namespace, repository, image_id):
|
|||
def list_tag_images(namespace, repository, tag):
|
||||
permission = ReadRepositoryPermission(namespace, repository)
|
||||
if permission.can() or model.repository_is_public(namespace, repository):
|
||||
tag_image = model.get_tag_image(namespace, repository, tag)
|
||||
try:
|
||||
tag_image = model.get_tag_image(namespace, repository, tag)
|
||||
except model.DataModelException:
|
||||
abort(404)
|
||||
|
||||
parent_images = model.get_parent_images(tag_image)
|
||||
|
||||
parents = list(parent_images)
|
||||
|
@ -522,42 +899,101 @@ def list_tag_images(namespace, repository, tag):
|
|||
abort(403) # Permission denied
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>/permissions/', methods=['GET'])
|
||||
@app.route('/api/repository/<path:repository>/permissions/team/',
|
||||
methods=['GET'])
|
||||
@api_login_required
|
||||
@parse_repository_name
|
||||
def list_repo_permissions(namespace, repository):
|
||||
def list_repo_team_permissions(namespace, repository):
|
||||
permission = AdministerRepositoryPermission(namespace, repository)
|
||||
if permission.can():
|
||||
repo_perms = model.get_all_repo_users(namespace, repository)
|
||||
repo_perms = model.get_all_repo_teams(namespace, repository)
|
||||
|
||||
return jsonify({
|
||||
'permissions': {repo_perm.user.username: role_view(repo_perm)
|
||||
'permissions': {repo_perm.team.name: role_view(repo_perm)
|
||||
for repo_perm in repo_perms}
|
||||
})
|
||||
|
||||
abort(403) # Permission denied
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>/permissions/<username>',
|
||||
@app.route('/api/repository/<path:repository>/permissions/user/',
|
||||
methods=['GET'])
|
||||
@api_login_required
|
||||
@parse_repository_name
|
||||
def get_permissions(namespace, repository, username):
|
||||
def list_repo_user_permissions(namespace, repository):
|
||||
permission = AdministerRepositoryPermission(namespace, repository)
|
||||
if permission.can():
|
||||
# Determine how to wrap the permissions
|
||||
role_view_func = role_view
|
||||
try:
|
||||
model.get_organization(namespace) # Will raise an error if not org
|
||||
org_members = model.get_organization_member_set(namespace)
|
||||
def wrapped_role_view(repo_perm):
|
||||
unwrapped = role_view(repo_perm)
|
||||
return wrap_role_view_org(unwrapped,
|
||||
repo_perm.user.username in org_members)
|
||||
|
||||
role_view_func = wrapped_role_view
|
||||
|
||||
except model.InvalidOrganizationException:
|
||||
# This repository isn't under an org
|
||||
pass
|
||||
|
||||
repo_perms = model.get_all_repo_users(namespace, repository)
|
||||
return jsonify({
|
||||
'permissions': {perm.user.username: role_view_func(perm)
|
||||
for perm in repo_perms}
|
||||
})
|
||||
|
||||
abort(403) # Permission denied
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>/permissions/user/<username>',
|
||||
methods=['GET'])
|
||||
@api_login_required
|
||||
@parse_repository_name
|
||||
def get_user_permissions(namespace, repository, username):
|
||||
logger.debug('Get repo: %s/%s permissions for user %s' %
|
||||
(namespace, repository, username))
|
||||
permission = AdministerRepositoryPermission(namespace, repository)
|
||||
if permission.can():
|
||||
perm = model.get_user_reponame_permission(username, namespace, repository)
|
||||
perm_view = role_view(perm)
|
||||
|
||||
try:
|
||||
model.get_organization(namespace)
|
||||
org_members = model.get_organization_member_set(namespace)
|
||||
perm_view = wrap_role_view_org(perm_view,
|
||||
perm.user.username in org_members)
|
||||
except model.InvalidOrganizationException:
|
||||
# This repository is not part of an organization
|
||||
pass
|
||||
|
||||
return jsonify(perm_view)
|
||||
|
||||
abort(403) # Permission denied
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>/permissions/team/<teamname>',
|
||||
methods=['GET'])
|
||||
@api_login_required
|
||||
@parse_repository_name
|
||||
def get_team_permissions(namespace, repository, teamname):
|
||||
logger.debug('Get repo: %s/%s permissions for team %s' %
|
||||
(namespace, repository, teamname))
|
||||
permission = AdministerRepositoryPermission(namespace, repository)
|
||||
if permission.can():
|
||||
perm = model.get_team_reponame_permission(teamname, namespace, repository)
|
||||
return jsonify(role_view(perm))
|
||||
|
||||
abort(403) # Permission denied
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>/permissions/<username>',
|
||||
@app.route('/api/repository/<path:repository>/permissions/user/<username>',
|
||||
methods=['PUT', 'POST'])
|
||||
@api_login_required
|
||||
@parse_repository_name
|
||||
def change_permissions(namespace, repository, username):
|
||||
def change_user_permissions(namespace, repository, username):
|
||||
permission = AdministerRepositoryPermission(namespace, repository)
|
||||
if permission.can():
|
||||
new_permission = request.get_json()
|
||||
|
@ -565,12 +1001,48 @@ def change_permissions(namespace, repository, username):
|
|||
logger.debug('Setting permission to: %s for user %s' %
|
||||
(new_permission['role'], username))
|
||||
|
||||
perm = model.set_user_repo_permission(username, namespace, repository,
|
||||
new_permission['role'])
|
||||
perm_view = role_view(perm)
|
||||
|
||||
try:
|
||||
perm = model.set_user_repo_permission(username, namespace, repository,
|
||||
new_permission['role'])
|
||||
except model.DataModelException:
|
||||
logger.warning('User tried to remove themselves as admin.')
|
||||
abort(409)
|
||||
model.get_organization(namespace)
|
||||
org_members = model.get_organization_member_set(namespace)
|
||||
perm_view = wrap_role_view_org(perm_view,
|
||||
perm.user.username in org_members)
|
||||
except model.InvalidOrganizationException:
|
||||
# This repository is not part of an organization
|
||||
pass
|
||||
except model.DataModelException as ex:
|
||||
error_resp = jsonify({
|
||||
'message': ex.message,
|
||||
})
|
||||
error_resp.status_code = 400
|
||||
return error_resp
|
||||
|
||||
|
||||
resp = jsonify(perm_view)
|
||||
if request.method == 'POST':
|
||||
resp.status_code = 201
|
||||
return resp
|
||||
|
||||
abort(403) # Permission denied
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>/permissions/team/<teamname>',
|
||||
methods=['PUT', 'POST'])
|
||||
@api_login_required
|
||||
@parse_repository_name
|
||||
def change_team_permissions(namespace, repository, teamname):
|
||||
permission = AdministerRepositoryPermission(namespace, repository)
|
||||
if permission.can():
|
||||
new_permission = request.get_json()
|
||||
|
||||
logger.debug('Setting permission to: %s for team %s' %
|
||||
(new_permission['role'], teamname))
|
||||
|
||||
perm = model.set_team_repo_permission(teamname, namespace, repository,
|
||||
new_permission['role'])
|
||||
|
||||
resp = jsonify(role_view(perm))
|
||||
if request.method == 'POST':
|
||||
|
@ -580,19 +1052,35 @@ def change_permissions(namespace, repository, username):
|
|||
abort(403) # Permission denied
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>/permissions/<username>',
|
||||
@app.route('/api/repository/<path:repository>/permissions/user/<username>',
|
||||
methods=['DELETE'])
|
||||
@api_login_required
|
||||
@parse_repository_name
|
||||
def delete_permissions(namespace, repository, username):
|
||||
def delete_user_permissions(namespace, repository, username):
|
||||
permission = AdministerRepositoryPermission(namespace, repository)
|
||||
if permission.can():
|
||||
try:
|
||||
model.delete_user_permission(username, namespace, repository)
|
||||
except model.DataModelException:
|
||||
logger.warning('User tried to remove themselves as admin.')
|
||||
abort(409)
|
||||
except model.DataModelException as ex:
|
||||
error_resp = jsonify({
|
||||
'message': ex.message,
|
||||
})
|
||||
error_resp.status_code = 400
|
||||
return error_resp
|
||||
|
||||
return make_response('Deleted', 204)
|
||||
|
||||
abort(403) # Permission denied
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>/permissions/team/<teamname>',
|
||||
methods=['DELETE'])
|
||||
@api_login_required
|
||||
@parse_repository_name
|
||||
def delete_team_permissions(namespace, repository, teamname):
|
||||
permission = AdministerRepositoryPermission(namespace, repository)
|
||||
if permission.can():
|
||||
model.delete_team_permission(teamname, namespace, repository)
|
||||
return make_response('Deleted', 204)
|
||||
|
||||
abort(403) # Permission denied
|
||||
|
@ -695,19 +1183,27 @@ def subscription_view(stripe_subscription, used_repos):
|
|||
|
||||
@app.route('/api/user/plan', methods=['PUT'])
|
||||
@api_login_required
|
||||
def subscribe():
|
||||
# Amount in cents
|
||||
amount = 500
|
||||
|
||||
def subscribe_api():
|
||||
request_data = request.get_json()
|
||||
plan = request_data['plan']
|
||||
|
||||
token = request_data['token'] if 'token' in request_data else None
|
||||
user = current_user.db_user()
|
||||
return subscribe(user, plan, token, USER_PLANS)
|
||||
|
||||
def subscribe(user, plan, token, accepted_plans):
|
||||
plan_found = None
|
||||
for plan_obj in accepted_plans:
|
||||
if plan_obj['stripeId'] == plan:
|
||||
plan_found = plan_obj
|
||||
|
||||
if not plan_found:
|
||||
abort(404)
|
||||
|
||||
private_repos = model.get_private_repo_count(user.username)
|
||||
|
||||
if not user.stripe_id:
|
||||
# Create the customer and plan simultaneously
|
||||
card = request_data['token']
|
||||
card = token
|
||||
cus = stripe.Customer.create(email=user.email, plan=plan, card=card)
|
||||
user.stripe_id = cus.id
|
||||
user.save()
|
||||
|
@ -720,29 +1216,41 @@ def subscribe():
|
|||
# Change the plan
|
||||
cus = stripe.Customer.retrieve(user.stripe_id)
|
||||
|
||||
if plan == 'free':
|
||||
if plan_found['price'] == 0:
|
||||
cus.cancel_subscription()
|
||||
cus.save()
|
||||
|
||||
response_json = {
|
||||
'plan': 'free',
|
||||
'plan': plan,
|
||||
'usedPrivateRepos': private_repos,
|
||||
}
|
||||
|
||||
else:
|
||||
cus.plan = plan
|
||||
|
||||
# User may have been a previous customer who is resubscribing
|
||||
if 'token' in request_data:
|
||||
cus.card = request_data['token']
|
||||
if token:
|
||||
cus.card = token
|
||||
|
||||
cus.save()
|
||||
|
||||
response_json = subscription_view(cus.subscription, private_repos)
|
||||
|
||||
return jsonify(response_json)
|
||||
|
||||
|
||||
@app.route('/api/organization/<orgname>/plan', methods=['PUT'])
|
||||
@api_login_required
|
||||
def subscribe_org_api(orgname):
|
||||
permission = AdministerOrganizationPermission(orgname)
|
||||
if permission.can():
|
||||
request_data = request.get_json()
|
||||
plan = request_data['plan']
|
||||
token = request_data['token'] if 'token' in request_data else None
|
||||
organization = model.get_organization(orgname)
|
||||
return subscribe(organization, plan, token, BUSINESS_PLANS)
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
@app.route('/api/user/plan', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_subscription():
|
||||
|
@ -752,10 +1260,31 @@ def get_subscription():
|
|||
if user.stripe_id:
|
||||
cus = stripe.Customer.retrieve(user.stripe_id)
|
||||
|
||||
if cus.subscription:
|
||||
if cus.subscription:
|
||||
return jsonify(subscription_view(cus.subscription, private_repos))
|
||||
|
||||
return jsonify({
|
||||
'plan': 'free',
|
||||
'usedPrivateRepos': private_repos,
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/organization/<orgname>/plan', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_org_subscription(orgname):
|
||||
permission = AdministerOrganizationPermission(orgname)
|
||||
if permission.can():
|
||||
private_repos = model.get_private_repo_count(orgname)
|
||||
organization = model.get_organization(orgname)
|
||||
if organization.stripe_id:
|
||||
cus = stripe.Customer.retrieve(organization.stripe_id)
|
||||
|
||||
if cus.subscription:
|
||||
return jsonify(subscription_view(cus.subscription, private_repos))
|
||||
|
||||
return jsonify({
|
||||
'plan': 'bus-free',
|
||||
'usedPrivateRepos': private_repos,
|
||||
})
|
||||
|
||||
abort(403)
|
||||
|
|
|
@ -13,8 +13,9 @@ from auth.auth import (process_auth, get_authenticated_user,
|
|||
get_validated_token)
|
||||
from util.names import parse_namespace_repository, parse_repository_name
|
||||
from util.email import send_confirmation_email
|
||||
from auth.permissions import (ModifyRepositoryPermission,
|
||||
ReadRepositoryPermission, UserPermission)
|
||||
from auth.permissions import (ModifyRepositoryPermission, UserPermission,
|
||||
ReadRepositoryPermission,
|
||||
CreateRepositoryPermission)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -77,10 +78,12 @@ def create_user():
|
|||
@app.route('/v1/users/', methods=['GET'])
|
||||
@process_auth
|
||||
def get_user():
|
||||
return jsonify({
|
||||
'username': get_authenticated_user().username,
|
||||
'email': get_authenticated_user().email,
|
||||
})
|
||||
if get_authenticated_user():
|
||||
return jsonify({
|
||||
'username': get_authenticated_user().username,
|
||||
'email': get_authenticated_user().email,
|
||||
})
|
||||
abort(404)
|
||||
|
||||
|
||||
@app.route('/v1/users/<username>/', methods=['PUT'])
|
||||
|
@ -127,7 +130,9 @@ def create_repository(namespace, repository):
|
|||
abort(403)
|
||||
|
||||
else:
|
||||
if get_authenticated_user().username != namespace:
|
||||
permission = CreateRepoPermission('namespace')
|
||||
if not permission.can():
|
||||
logger.info('Attempt to create a new repo with insufficient perms.')
|
||||
abort(403)
|
||||
|
||||
logger.debug('Creaing repository with owner: %s' %
|
||||
|
@ -216,18 +221,18 @@ def get_repository_images(namespace, repository):
|
|||
@parse_repository_name
|
||||
@generate_headers(role='write')
|
||||
def delete_repository_images(namespace, repository):
|
||||
pass
|
||||
return make_response('Not Implemented', 501)
|
||||
|
||||
|
||||
@app.route('/v1/repositories/<path:repository>/auth', methods=['PUT'])
|
||||
@parse_repository_name
|
||||
def put_repository_auth(namespace, repository):
|
||||
pass
|
||||
return make_response('Not Implemented', 501)
|
||||
|
||||
|
||||
@app.route('/v1/search', methods=['GET'])
|
||||
def get_search():
|
||||
pass
|
||||
return make_response('Not Implemented', 501)
|
||||
|
||||
|
||||
@app.route('/_ping')
|
||||
|
|
|
@ -6,7 +6,6 @@ from functools import wraps
|
|||
from datetime import datetime
|
||||
from time import time
|
||||
|
||||
import storage
|
||||
from data.queue import image_diff_queue
|
||||
|
||||
from app import app
|
||||
|
@ -17,7 +16,7 @@ from auth.permissions import (ReadRepositoryPermission,
|
|||
from data import model
|
||||
|
||||
|
||||
store = storage.load()
|
||||
store = app.config['STORAGE']
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
|
@ -4,8 +4,6 @@ import json
|
|||
|
||||
from flask import abort, request, jsonify, make_response
|
||||
|
||||
import storage
|
||||
|
||||
from app import app
|
||||
from util.names import parse_repository_name
|
||||
from auth.auth import process_auth
|
||||
|
@ -14,7 +12,6 @@ from auth.permissions import (ReadRepositoryPermission,
|
|||
from data import model
|
||||
|
||||
|
||||
store = storage.load()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
@ -71,7 +68,7 @@ def delete_tag(namespace, repository, tag):
|
|||
permission = ModifyRepositoryPermission(namespace, repository)
|
||||
|
||||
if permission.can():
|
||||
model.delete_tag(namespace, repository, tag_name)
|
||||
model.delete_tag(namespace, repository, tag)
|
||||
|
||||
return make_response('Deleted', 204)
|
||||
|
||||
|
|
|
@ -43,6 +43,7 @@ def load_user(username):
|
|||
|
||||
@app.route('/', methods=['GET'], defaults={'path': ''})
|
||||
@app.route('/repository/<path:path>', methods=['GET'])
|
||||
@app.route('/organization/<path:path>', methods=['GET'])
|
||||
def index(path):
|
||||
return render_template('index.html')
|
||||
|
||||
|
@ -57,6 +58,11 @@ def guide():
|
|||
return index('')
|
||||
|
||||
|
||||
@app.route('/organizations/')
|
||||
@app.route('/organizations/new/')
|
||||
def organizations():
|
||||
return index('')
|
||||
|
||||
@app.route('/user/')
|
||||
def user():
|
||||
return index('')
|
||||
|
@ -167,7 +173,6 @@ def github_oauth_callback():
|
|||
if common_login(to_login):
|
||||
return redirect(url_for('index'))
|
||||
|
||||
# TODO something bad happened, we need to tell the user somehow
|
||||
return render_template('githuberror.html')
|
||||
|
||||
|
||||
|
|
189
initdb.py
|
@ -6,23 +6,21 @@ import hashlib
|
|||
|
||||
from datetime import datetime, timedelta
|
||||
from flask import url_for
|
||||
from peewee import SqliteDatabase, create_model_tables, drop_model_tables
|
||||
|
||||
import storage
|
||||
|
||||
from data.database import initialize_db
|
||||
from data.database import *
|
||||
from data import model
|
||||
from app import app
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
store = storage.load()
|
||||
logging.basicConfig(**app.config['LOGGING_CONFIG'])
|
||||
|
||||
store = app.config['STORAGE']
|
||||
|
||||
SAMPLE_DIFFS = ['test/data/sample/diffs/diffs%s.json' % i
|
||||
for i in range(1, 10)]
|
||||
|
||||
REFERENCE_DATE = datetime(2013, 6, 23)
|
||||
TEST_STRIPE_ID = 'cus_2tmnh3PkXQS8NG'
|
||||
|
||||
|
||||
def __gen_checksum(image_id):
|
||||
|
@ -38,7 +36,7 @@ def __gen_image_id(repo, image_num):
|
|||
|
||||
|
||||
global_image_num = [0]
|
||||
def create_subtree(repo, structure, parent):
|
||||
def __create_subtree(repo, structure, parent):
|
||||
num_nodes, subtrees, last_node_tags = structure
|
||||
|
||||
# create the nodes
|
||||
|
@ -76,7 +74,7 @@ def create_subtree(repo, structure, parent):
|
|||
new_image.docker_image_id)
|
||||
|
||||
for subtree in subtrees:
|
||||
create_subtree(repo, subtree, new_image)
|
||||
__create_subtree(repo, subtree, new_image)
|
||||
|
||||
|
||||
def __generate_repository(user, name, description, is_public, permissions,
|
||||
|
@ -94,65 +92,130 @@ def __generate_repository(user, name, description, is_public, permissions,
|
|||
model.set_user_repo_permission(delegate.username, user.username, name,
|
||||
role)
|
||||
|
||||
create_subtree(repo, structure, None)
|
||||
__create_subtree(repo, structure, None)
|
||||
|
||||
return repo
|
||||
|
||||
|
||||
def initialize_database():
|
||||
create_model_tables(all_models)
|
||||
|
||||
Role.create(name='admin')
|
||||
Role.create(name='write')
|
||||
Role.create(name='read')
|
||||
TeamRole.create(name='admin')
|
||||
TeamRole.create(name='creator')
|
||||
TeamRole.create(name='member')
|
||||
Visibility.create(name='public')
|
||||
Visibility.create(name='private')
|
||||
LoginService.create(name='github')
|
||||
|
||||
|
||||
def wipe_database():
|
||||
logger.debug('Wiping all data from the DB.')
|
||||
|
||||
# Sanity check to make sure we're not killing our prod db
|
||||
db = model.db
|
||||
if (not isinstance(model.db, SqliteDatabase) or
|
||||
app.config['DB_DRIVER'] is not SqliteDatabase):
|
||||
raise RuntimeError('Attempted to wipe production database!')
|
||||
|
||||
drop_model_tables(all_models, fail_silently=True)
|
||||
|
||||
|
||||
def populate_database():
|
||||
logger.debug('Populating the DB with test data.')
|
||||
|
||||
new_user_1 = model.create_user('devtable', 'password',
|
||||
'jschorr@devtable.com')
|
||||
new_user_1.verified = True
|
||||
new_user_1.save()
|
||||
|
||||
new_user_2 = model.create_user('public', 'password',
|
||||
'jacob.moshenko@gmail.com')
|
||||
new_user_2.verified = True
|
||||
new_user_2.save()
|
||||
|
||||
new_user_3 = model.create_user('freshuser', 'password', 'no@thanks.com')
|
||||
new_user_3.verified = True
|
||||
new_user_3.save()
|
||||
|
||||
reader = model.create_user('reader', 'password', 'no1@thanks.com')
|
||||
reader.verified = True
|
||||
reader.save()
|
||||
|
||||
outside_org = model.create_user('outsideorg', 'password', 'no2@thanks.com')
|
||||
outside_org.verified = True
|
||||
outside_org.save()
|
||||
|
||||
__generate_repository(new_user_1, 'simple', 'Simple repository.', False,
|
||||
[], (4, [], ['latest', 'prod']))
|
||||
|
||||
__generate_repository(new_user_1, 'complex',
|
||||
'Complex repository with many branches and tags.',
|
||||
False, [(new_user_2, 'read')],
|
||||
(2, [(3, [], 'v2.0'),
|
||||
(1, [(1, [(1, [], ['prod'])],
|
||||
'staging'),
|
||||
(1, [], None)], None)], None))
|
||||
|
||||
__generate_repository(new_user_1, 'gargantuan', None, False, [],
|
||||
(2, [(3, [], 'v2.0'),
|
||||
(1, [(1, [(1, [], ['latest', 'prod'])],
|
||||
'staging'),
|
||||
(1, [], None)], None),
|
||||
(20, [], 'v3.0'),
|
||||
(5, [], 'v4.0'),
|
||||
(1, [(1, [], 'v5.0'), (1, [], 'v6.0')], None)],
|
||||
None))
|
||||
|
||||
__generate_repository(new_user_2, 'publicrepo',
|
||||
'Public repository pullable by the world.', True,
|
||||
[], (10, [], 'latest'))
|
||||
|
||||
__generate_repository(new_user_1, 'shared',
|
||||
'Shared repository, another user can write.', False,
|
||||
[(new_user_2, 'write'), (reader, 'read')],
|
||||
(5, [], 'latest'))
|
||||
|
||||
building = __generate_repository(new_user_1, 'building',
|
||||
'Empty repository which is building.',
|
||||
False, [], (0, [], None))
|
||||
|
||||
org = model.create_organization('buynlarge', 'quay@devtable.com',
|
||||
new_user_1)
|
||||
org.stripe_id = TEST_STRIPE_ID
|
||||
org.save()
|
||||
|
||||
owners = model.get_organization_team('buynlarge', 'owners')
|
||||
owners.description = 'Owners have unfetterd access across the entire org.'
|
||||
owners.save()
|
||||
|
||||
org_repo = __generate_repository(org, 'orgrepo',
|
||||
'Repository owned by an org.', False,
|
||||
[(outside_org, 'read')],
|
||||
(4, [], ['latest', 'prod']))
|
||||
|
||||
reader_team = model.create_team('readers', org, 'member',
|
||||
'Readers of orgrepo.')
|
||||
model.set_team_repo_permission(reader_team.name, org_repo.namespace,
|
||||
org_repo.name, 'read')
|
||||
model.add_user_to_team(new_user_2, reader_team)
|
||||
model.add_user_to_team(reader, reader_team)
|
||||
|
||||
token = model.create_access_token(building, 'write')
|
||||
tag = 'ci.devtable.com:5000/%s/%s' % (building.namespace, building.name)
|
||||
build = model.create_repository_build(building, token, '123-45-6789', tag)
|
||||
|
||||
build.build_node_id = 1
|
||||
build.phase = 'building'
|
||||
build.status_url = 'http://localhost:5000/test/build/status'
|
||||
build.save()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
initialize_db()
|
||||
logging.basicConfig(**app.config['LOGGING_CONFIG'])
|
||||
initialize_database()
|
||||
|
||||
if app.config.get('POPULATE_DB_TEST_DATA', False):
|
||||
logger.debug('Populating the DB with test data.')
|
||||
|
||||
new_user_1 = model.create_user('devtable', 'password',
|
||||
'jschorr@devtable.com')
|
||||
new_user_1.verified = True
|
||||
new_user_1.save()
|
||||
|
||||
new_user_2 = model.create_user('public', 'password',
|
||||
'jacob.moshenko@gmail.com')
|
||||
new_user_2.verified = True
|
||||
new_user_2.save()
|
||||
|
||||
__generate_repository(new_user_1, 'simple', 'Simple repository.', False,
|
||||
[], (4, [], ['latest', 'prod']))
|
||||
|
||||
__generate_repository(new_user_1, 'complex',
|
||||
'Complex repository with many branches and tags.',
|
||||
False, [(new_user_2, 'read')],
|
||||
(2, [(3, [], 'v2.0'),
|
||||
(1, [(1, [(1, [], ['prod'])],
|
||||
'staging'),
|
||||
(1, [], None)], None)], None))
|
||||
|
||||
__generate_repository(new_user_1, 'gargantuan', None, False, [],
|
||||
(2, [(3, [], 'v2.0'),
|
||||
(1, [(1, [(1, [], ['latest', 'prod'])],
|
||||
'staging'),
|
||||
(1, [], None)], None),
|
||||
(20, [], 'v3.0'),
|
||||
(5, [], 'v4.0'),
|
||||
(1, [(1, [], 'v5.0'), (1, [], 'v6.0')], None)],
|
||||
None))
|
||||
|
||||
__generate_repository(new_user_2, 'publicrepo',
|
||||
'Public repository pullable by the world.', True,
|
||||
[], (10, [], 'latest'))
|
||||
|
||||
__generate_repository(new_user_1, 'shared',
|
||||
'Shared repository, another user can write.', False,
|
||||
[(new_user_2, 'write')], (5, [], 'latest'))
|
||||
|
||||
building = __generate_repository(new_user_1, 'building',
|
||||
'Empty repository which is building.',
|
||||
False, [], (0, [], None))
|
||||
|
||||
token = model.create_access_token(building, 'write')
|
||||
tag = 'ci.devtable.com:5000/%s/%s' % (building.namespace, building.name)
|
||||
build = model.create_repository_build(building, token, '123-45-6789', tag)
|
||||
|
||||
build.build_node_id = 1
|
||||
build.phase = 'building'
|
||||
build.status_url = 'http://localhost:5000/test/build/status'
|
||||
build.save()
|
||||
populate_database()
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
var width = 993;
|
||||
var width = 1024;
|
||||
var height = 768;
|
||||
|
||||
var casper = require('casper').create({
|
||||
|
@ -12,12 +12,14 @@ var casper = require('casper').create({
|
|||
|
||||
var disableOlark = function() {
|
||||
casper.then(function() {
|
||||
this.waitForText('Contact us!', function() {
|
||||
this.waitForText('Chat with us!', function() {
|
||||
this.evaluate(function() {
|
||||
console.log(olark);
|
||||
window.olark.configure('box.start_hidden', true);
|
||||
window.olark('api.box.hide');
|
||||
});
|
||||
}, function() {
|
||||
// Do nothing, if olark never loaded we're ok with that
|
||||
});
|
||||
});
|
||||
};
|
||||
|
@ -27,6 +29,8 @@ var isDebug = !!options['d'];
|
|||
|
||||
var rootUrl = isDebug ? 'http://localhost:5000/' : 'https://quay.io/';
|
||||
var repo = isDebug ? 'complex' : 'r0';
|
||||
var org = isDebug ? 'buynlarge' : 'quay'
|
||||
var orgrepo = 'orgrepo'
|
||||
|
||||
var outputDir = "screenshots/";
|
||||
|
||||
|
@ -42,11 +46,11 @@ casper.start(rootUrl + 'signin', function () {
|
|||
this.fill('.form-signin', {
|
||||
'username': 'devtable',
|
||||
'password': isDebug ? 'password': 'C>K98%y"_=54x"<',
|
||||
}, true);
|
||||
}, false);
|
||||
});
|
||||
|
||||
casper.then(function() {
|
||||
this.waitForText('Your Top Repositories');
|
||||
casper.thenClick('.form-signin button[type=submit]', function() {
|
||||
this.waitForText('Top Repositories');
|
||||
});
|
||||
|
||||
disableOlark();
|
||||
|
@ -90,4 +94,44 @@ casper.then(function() {
|
|||
this.capture(outputDir + 'repo-admin.png');
|
||||
});
|
||||
|
||||
casper.thenOpen(rootUrl + 'repository/?namespace=' + org, function() {
|
||||
this.waitForText('Repositories');
|
||||
});
|
||||
|
||||
disableOlark();
|
||||
|
||||
casper.then(function() {
|
||||
this.capture(outputDir + 'org-repo-list.png');
|
||||
});
|
||||
|
||||
casper.thenOpen(rootUrl + 'organization/' + org, function() {
|
||||
this.waitForSelector('.organization-name');
|
||||
});
|
||||
|
||||
disableOlark();
|
||||
|
||||
casper.then(function() {
|
||||
this.capture(outputDir + 'org-teams.png');
|
||||
});
|
||||
|
||||
casper.thenOpen(rootUrl + 'organization/' + org + '/admin', function() {
|
||||
this.waitForSelector('#repository-usage-chart');
|
||||
});
|
||||
|
||||
disableOlark();
|
||||
|
||||
casper.then(function() {
|
||||
this.capture(outputDir + 'org-admin.png');
|
||||
});
|
||||
|
||||
casper.thenOpen(rootUrl + 'repository/' + org + '/' + orgrepo + '/admin', function() {
|
||||
this.waitForText('outsideorg')
|
||||
});
|
||||
|
||||
disableOlark();
|
||||
|
||||
casper.then(function() {
|
||||
this.capture(outputDir + 'org-repo-admin.png');
|
||||
});
|
||||
|
||||
casper.run();
|
||||
|
|
|
@ -2,6 +2,70 @@
|
|||
font-family: 'Droid Sans', sans-serif;
|
||||
}
|
||||
|
||||
.button-hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.organization-header-element {
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.organization-header-element .organization-name {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.organization-header-element .divider {
|
||||
color: #aaa;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.organization-header-element .organization-name {
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.organization-header-element .team-name {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.organization-header-element .header-buttons {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.namespace-selector-dropdown .namespace {
|
||||
padding: 6px;
|
||||
padding-left: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.namespace-selector-dropdown .namespace-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.namespace-selector-dropdown .namespace-item .fa {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 12px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.namespace-selector-dropdown .namespace-item.disabled img {
|
||||
-webkit-filter: grayscale(1);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.namespace-selector-dropdown .namespace-item .tooltip-inner {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.user-notification {
|
||||
background: red;
|
||||
}
|
||||
|
@ -179,10 +243,6 @@
|
|||
color: #444 !important;
|
||||
}
|
||||
|
||||
.new-repo .new-header span {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.new-repo .new-header .popover {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
@ -277,24 +337,46 @@
|
|||
color: #428bca;
|
||||
}
|
||||
|
||||
.plans .all-plans .business-feature {
|
||||
color: #46ac39;
|
||||
}
|
||||
|
||||
.plans-list {
|
||||
text-align: center;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.plans-list .plan-container {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.plans-list .plan {
|
||||
width: 245px;
|
||||
vertical-align: top;
|
||||
|
||||
display: inline-block;
|
||||
padding: 10px;
|
||||
margin-right: 10px;
|
||||
border: 1px solid #eee;
|
||||
border-top: 4px solid #94C9F7;
|
||||
|
||||
margin-top: 10px;
|
||||
|
||||
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 {
|
||||
border: 1px solid #ddd;
|
||||
border-top: 4px solid #47A447;
|
||||
margin-top: 0px;
|
||||
font-size: 1.6em;
|
||||
}
|
||||
|
||||
.plans-list .plan:last-child {
|
||||
|
@ -317,7 +399,7 @@
|
|||
}
|
||||
|
||||
.plan-price:after {
|
||||
content: "/ month";
|
||||
content: "/ mo";
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
right: 20px;
|
||||
|
@ -333,6 +415,10 @@
|
|||
color: #428bca;
|
||||
}
|
||||
|
||||
.plans-list .plan.business-plan .count b {
|
||||
color: #46ac39;
|
||||
}
|
||||
|
||||
.plans-list .plan .description {
|
||||
font-size: 1em;
|
||||
font-size: 16px;
|
||||
|
@ -354,14 +440,6 @@
|
|||
margin-right: 5px;
|
||||
}
|
||||
|
||||
|
||||
.plans-list .plan.small {
|
||||
border: 1px solid #ddd;
|
||||
border-top: 4px solid #428bca;
|
||||
margin-top: 0px;
|
||||
font-size: 1.6em;
|
||||
}
|
||||
|
||||
.plans .plan-faq dd{
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
@ -575,14 +653,20 @@ form input.ng-valid.ng-dirty {
|
|||
}
|
||||
|
||||
|
||||
.user-mini-listing {
|
||||
.entity-mini-listing {
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.user-mini-listing i {
|
||||
.entity-mini-listing i {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.entity-mini-listing .warning {
|
||||
margin-top: 6px;
|
||||
font-size: 10px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.editable {
|
||||
position: relative;
|
||||
}
|
||||
|
@ -611,9 +695,8 @@ p.editable {
|
|||
display: inline-block;
|
||||
}
|
||||
|
||||
p.editable .content:empty:after {
|
||||
p.editable .empty {
|
||||
display: inline-block;
|
||||
content: "(Click to add)";
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
|
@ -867,6 +950,25 @@ p.editable:hover i {
|
|||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.repo-list .button-bar-right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.button-bar-bottom {
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
|
||||
.repo-list .section-header {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.repo-list .button-bar-right button {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.repo-listing {
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
|
@ -899,6 +1001,10 @@ p.editable:hover i {
|
|||
padding-left: 44px;
|
||||
}
|
||||
|
||||
.repo-admin .entity-search input {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.repo-admin .token-dialog-body .well {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
@ -916,20 +1022,35 @@ p.editable:hover i {
|
|||
width: 620px;
|
||||
}
|
||||
|
||||
.repo-admin .user i {
|
||||
margin-right: 6px;
|
||||
.repo-admin .user i.fa-user {
|
||||
margin-left: 2px;
|
||||
margin-right: 7px;
|
||||
}
|
||||
|
||||
.repo-admin .user {
|
||||
.repo-admin .team i.fa-group {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.repo-admin .entity {
|
||||
font-size: 1.2em;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.repo-admin .entity .popover {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.repo-admin .entity i.fa-exclamation-triangle {
|
||||
color: #c09853;
|
||||
float: right;
|
||||
margin-right: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.repo-admin .token a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.repo .build-info {
|
||||
padding: 10px;
|
||||
margin: 0px;
|
||||
|
@ -1100,7 +1221,7 @@ p.editable:hover i {
|
|||
}
|
||||
|
||||
.delete-ui:focus .delete-ui-button {
|
||||
width: 54px;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.repo-admin .repo-delete {
|
||||
|
@ -1160,12 +1281,13 @@ p.editable:hover i {
|
|||
border: inherit;
|
||||
}
|
||||
|
||||
.user-admin .panel-plan {
|
||||
.user-admin #migrate .panel {
|
||||
max-width: 600px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.user-admin .panel-plan .button-hidden {
|
||||
visibility: hidden;
|
||||
.user-admin .panel-plan {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.user-admin .plan-description {
|
||||
|
@ -1183,6 +1305,41 @@ p.editable:hover i {
|
|||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.user-admin .convert-form h3 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.user-admin #convertForm {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.user-admin #convertForm .form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.user-admin #convertForm input {
|
||||
margin-bottom: 10px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.user-admin #convertForm .existing-data {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.user-admin #convertForm .description {
|
||||
margin-top: 10px;
|
||||
display: block;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.user-admin #convertForm .existing-data {
|
||||
display: block;
|
||||
padding-left: 20px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
#image-history-container {
|
||||
overflow: hidden;
|
||||
|
@ -1280,6 +1437,49 @@ p.editable:hover i {
|
|||
stroke-width: 1.5px;
|
||||
}
|
||||
|
||||
#repository-usage-chart {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
#repository-usage-chart .count-text {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
#repository-usage-chart.limit-at path.arc-0 {
|
||||
fill: #c09853;
|
||||
}
|
||||
|
||||
#repository-usage-chart.limit-over path.arc-0 {
|
||||
fill: #b94a48;
|
||||
}
|
||||
|
||||
#repository-usage-chart.limit-near path.arc-0 {
|
||||
fill: #468847;
|
||||
}
|
||||
|
||||
#repository-usage-chart.limit-over path.arc-1 {
|
||||
fill: #fcf8e3;
|
||||
}
|
||||
|
||||
#repository-usage-chart.limit-at path.arc-1 {
|
||||
fill: #f2dede;
|
||||
}
|
||||
|
||||
#repository-usage-chart.limit-near path.arc-1 {
|
||||
fill: #dff0d8;
|
||||
}
|
||||
|
||||
.plan-manager-element .usage-caption {
|
||||
display: inline-block;
|
||||
color: #aaa;
|
||||
font-size: 26px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* Overrides for the markdown editor. */
|
||||
|
||||
.wmd-panel .btn-toolbar {
|
||||
|
@ -1309,6 +1509,271 @@ p.editable:hover i {
|
|||
min-height: 50px;
|
||||
}
|
||||
|
||||
.team-view .panel {
|
||||
display: inline-block;
|
||||
width: 620px;
|
||||
}
|
||||
|
||||
.team-view .entity {
|
||||
font-size: 1.2em;
|
||||
min-width: 510px;
|
||||
}
|
||||
|
||||
.team-view .entity i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.team-view .entity-search {
|
||||
margin-top: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.team-view .delete-ui {
|
||||
display: inline-block;
|
||||
width: 78px;
|
||||
}
|
||||
|
||||
.team-view .delete-ui i {
|
||||
margin-top: 8px;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.org-view .team-listing {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.org-view .header-col {
|
||||
color: #444;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.org-view .header-col dd {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.org-view .header-col .info-icon {
|
||||
float: none;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.org-view .team-listing .control-col button.btn-danger {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.org-view .team-listing i {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.org-view .highlight .team-title {
|
||||
animation: highlighttemp 1s 2;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-direction: alternate;
|
||||
|
||||
-moz-animation: highlighttemp 1s 2;
|
||||
-moz-animation-timing-function: ease-in-out;
|
||||
-moz-animation-direction: alternate;
|
||||
|
||||
-webkit-animation: highlighttemp 1s 2;
|
||||
-webkit-animation-timing-function: ease-in-out;
|
||||
-webkit-animation-direction: alternate;
|
||||
}
|
||||
|
||||
@-moz-keyframes highlighttemp {
|
||||
0% { background-color: white; }
|
||||
100% { background-color: rgba(92, 184, 92, 0.36); }
|
||||
}
|
||||
|
||||
@-webkit-keyframes highlighttemp {
|
||||
0% { background-color: white; }
|
||||
100% { background-color: rgba(92, 184, 92, 0.36); }
|
||||
}
|
||||
|
||||
@keyframes highlighttemp {
|
||||
0% { background-color: white; }
|
||||
100% { background-color: rgba(92, 184, 92, 0.36); }
|
||||
}
|
||||
|
||||
.org-view .team-title {
|
||||
font-size: 20px;
|
||||
text-transform: none;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.org-view .team-listing .team-description {
|
||||
margin-top: 6px;
|
||||
margin-left: 41px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.org-view #create-team-box {
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.org-admin .team-link {
|
||||
display: inline-block;
|
||||
text-transform: none;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.org-admin #members table td {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.org-admin #members table i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.org-admin #members .side-controls {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.org-admin #members .result-count {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.org-admin #members .filter-input {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.org-list h2 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.org-list .button-bar-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.org-list .organization-listing {
|
||||
font-size: 18px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.org-list .organization-listing img {
|
||||
margin-left: 10px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.create-org .steps-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.create-org .steps {
|
||||
|
||||
background: #222;
|
||||
|
||||
display: inline-block;
|
||||
margin-top: 16px;
|
||||
margin-left: 0px;
|
||||
border-radius: 4px;
|
||||
padding: 0px;
|
||||
list-style: none;
|
||||
height: 46px;
|
||||
width: 675px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
|
||||
.create-org .steps .step {
|
||||
width: 225px;
|
||||
float: left;
|
||||
padding: 10px;
|
||||
border-right: 1px solid #222;
|
||||
margin: 0px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #aaa;
|
||||
border-left: 4px solid transparent;
|
||||
}
|
||||
|
||||
.create-org .steps .step i {
|
||||
font-size: 26px;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.create-org .steps .step.active {
|
||||
color: white;
|
||||
border-left: 4px solid steelblue;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.create-org .steps .step:last-child {
|
||||
border-right: 0px;
|
||||
}
|
||||
|
||||
.create-org .steps .step b {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.create-org .button-bar {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.create-org .form-group {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.create-org .plan-group {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.create-org .plan-group strong {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.create-org .step-container .description {
|
||||
margin-top: 10px;
|
||||
display: block;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.create-org .form-group input {
|
||||
margin-top: 10px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.create-org h3 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.plan-manager-element .plans-list-table thead td {
|
||||
color: #aaa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.plan-manager-element .plans-list-table td {
|
||||
padding: 10px;
|
||||
font-size: 16px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.plan-manager-element .plans-list-table td.controls {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.plan-manager-element .plans-list-table .plan-price {
|
||||
font-size: 16px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.plans-table-element table {
|
||||
margin: 20px;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
|
||||
.plans-table-element td {
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
.plans-table-element .plan-price {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
/* Overrides for typeahead to work with bootstrap 3. */
|
||||
|
||||
.twitter-typeahead .tt-query,
|
||||
|
@ -1489,4 +1954,8 @@ p.editable:hover i {
|
|||
top: -9px;
|
||||
font-weight: bold;
|
||||
font-size: .4em;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
margin-bottom: 40px;
|
||||
}
|
1
static/directives/entity-search.html
Normal file
|
@ -0,0 +1 @@
|
|||
<input class="entity-search-control form-control">
|
31
static/directives/markdown-input.html
Normal file
|
@ -0,0 +1,31 @@
|
|||
<div class="markdown-input-container">
|
||||
<p ng-class="'lead ' + (canWrite ? 'editable' : 'noteditable')" ng-click="editContent()">
|
||||
<span class="markdown-view" content="content"></span>
|
||||
<span class="empty" ng-show="!content && canWrite">(Click to set {{ fieldTitle }})</span>
|
||||
<i class="fa fa-edit"></i>
|
||||
</p>
|
||||
|
||||
<!-- Modal editor -->
|
||||
<div class="modal fade">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Edit {{ fieldTitle }}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="wmd-panel">
|
||||
<div id="wmd-button-bar-description-{{id}}"></div>
|
||||
<textarea class="wmd-input" id="wmd-input-description-{{id}}" placeholder="Enter {{ fieldTitle }}">{{ content }}</textarea>
|
||||
</div>
|
||||
|
||||
<div id="wmd-preview-description-{{id}}" class="wmd-panel wmd-preview"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" ng-click="saveContent()">Save changes</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
</div>
|
1
static/directives/markdown-view.html
Normal file
|
@ -0,0 +1 @@
|
|||
<span class="markdown-view-content" ng-bind-html-unsafe="getMarkedDown(content, firstLineOnly)"></span>
|
34
static/directives/namespace-selector.html
Normal file
|
@ -0,0 +1,34 @@
|
|||
<span class="namespace-selector-dropdown">
|
||||
<span ng-show="user.organizations.length == 0">
|
||||
<img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=24&d=identicon" />
|
||||
<span class="namespace-name">{{user.username}}</span>
|
||||
</span>
|
||||
|
||||
<div class="btn-group" ng-show="user.organizations.length > 0">
|
||||
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
|
||||
<img src="//www.gravatar.com/avatar/{{ namespaceObj.gravatar }}?s=16&d=identicon" />
|
||||
{{namespace}} <span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<li class="namespace-item" ng-repeat="org in user.organizations"
|
||||
ng-class="(requireCreate && !namespaces[org.name].can_create_repo) ? 'disabled' : ''">
|
||||
<a class="namespace" href="javascript:void(0)" ng-click="setNamespace(org)">
|
||||
<img src="//www.gravatar.com/avatar/{{ org.gravatar }}?s=24&d=identicon" />
|
||||
<span class="namespace-name">{{ org.name }}</span>
|
||||
</a>
|
||||
|
||||
<i class="fa fa-exclamation-triangle" ng-show="requireCreate && !namespaces[org.name].can_create_repo"
|
||||
title="You do not have permission to create repositories for this organization"
|
||||
data-placement="right"
|
||||
bs-tooltip="tooltip.title"></i>
|
||||
</li>
|
||||
<li class="divider"></li>
|
||||
<li>
|
||||
<a class="namespace" href="javascript:void(0)" ng-click="setNamespace(user)">
|
||||
<img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=24&d=identicon" />
|
||||
<span class="namespace-name">{{ user.username }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</span>
|
18
static/directives/organization-header.html
Normal file
|
@ -0,0 +1,18 @@
|
|||
<div class="organization-header-element">
|
||||
<img src="//www.gravatar.com/avatar/{{ organization.gravatar }}?s=24&d=identicon">
|
||||
<span class="organization-name" ng-show="teamName">
|
||||
<a href="/organization/{{ organization.name }}">{{ organization.name }}</a>
|
||||
</span>
|
||||
<span class="organization-name" ng-show="!teamName">
|
||||
{{ organization.name }}
|
||||
</span>
|
||||
|
||||
<span ng-show="teamName">
|
||||
<span class="divider">/</span>
|
||||
<i class="fa fa-group"></i>
|
||||
<span class="team-name">
|
||||
{{ teamName }}
|
||||
</span>
|
||||
</span>
|
||||
<span ng-transclude></span>
|
||||
</div>
|
62
static/directives/plan-manager.html
Normal file
|
@ -0,0 +1,62 @@
|
|||
<div class="plan-manager-element">
|
||||
<!-- Loading/Changing -->
|
||||
<i class="fa fa-spinner fa-spin fa-3x" ng-show="planLoading"></i>
|
||||
|
||||
<!-- Alerts -->
|
||||
<div class="alert alert-danger" ng-show="limit == 'over' && !planLoading">
|
||||
You are using more private repositories than your plan allows. Please
|
||||
upgrade your subscription to avoid disruptions in your <span ng-show="organization">organization's</span> service.
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" ng-show="limit == 'at' && !planLoading">
|
||||
You are at your current plan's number of allowed private repositories. Please upgrade your subscription to avoid future disruptions in your <span ng-show="organization">organization's</span> service.
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success" ng-show="limit == 'near' && !planLoading">
|
||||
You are nearing the number of allowed private repositories. It might be time to think about
|
||||
upgrading your subscription to avoid future disruptions in your <span ng-show="organization">organization's</span> service.
|
||||
</div>
|
||||
|
||||
<!-- Chart -->
|
||||
<div>
|
||||
<div id="repository-usage-chart" class="limit-{{limit}}"></div>
|
||||
<span class="usage-caption" ng-show="chart">Repository Usage</span>
|
||||
</div>
|
||||
|
||||
<!-- Plans Table -->
|
||||
<table class="table table-hover plans-list-table" ng-show="!planLoading">
|
||||
<thead>
|
||||
<td>Plan</td>
|
||||
<td>Private Repositories</td>
|
||||
<td style="min-width: 64px">Price</td>
|
||||
<td></td>
|
||||
</thead>
|
||||
|
||||
<tr ng-repeat="plan in plans" ng-class="(subscribedPlan.stripeId === plan.stripeId) ? getActiveSubClass() : ''">
|
||||
<td>{{ plan.title }}</td>
|
||||
<td>{{ plan.privateRepos }}</td>
|
||||
<td><div class="plan-price">${{ plan.price / 100 }}</div></td>
|
||||
<td class="controls">
|
||||
<div ng-switch='plan.stripeId'>
|
||||
<div ng-switch-when='bus-free'>
|
||||
<button class="btn button-hidden">Hidden!</button>
|
||||
</div>
|
||||
<div ng-switch-default>
|
||||
<button class="btn" ng-show="subscribedPlan.stripeId !== plan.stripeId"
|
||||
ng-class="subscribedPlan.price == 0 ? 'btn-primary' : 'btn-default'"
|
||||
ng-click="changeSubscription(plan.stripeId)">
|
||||
<i class="fa fa-spinner fa-spin" ng-show="planChanging"></i>
|
||||
<span ng-show="!planChanging && subscribedPlan.price != 0">Change</span>
|
||||
<span ng-show="!planChanging && subscribedPlan.price == 0">Subscribe</span>
|
||||
</button>
|
||||
<button class="btn btn-danger" ng-show="subscription.plan === plan.stripeId && plan.price > 0"
|
||||
ng-click="cancelSubscription()">
|
||||
<i class="fa fa-spinner fa-spin" ng-show="planChanging"></i>
|
||||
<span ng-show="!planChanging">Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
23
static/directives/plans-table.html
Normal file
|
@ -0,0 +1,23 @@
|
|||
<div class="plans-table-element">
|
||||
<table class="table table-hover plans-table-table" ng-show="plans">
|
||||
<thead>
|
||||
<th>Plan</th>
|
||||
<th>Private Repositories</th>
|
||||
<th style="min-width: 85px">Price</th>
|
||||
<th></th>
|
||||
</thead>
|
||||
|
||||
<tr ng-repeat="plan in plans" ng-class="currentPlan == plan ? 'active' : ''">
|
||||
<td>{{ plan.title }}</td>
|
||||
<td>{{ plan.privateRepos }}</td>
|
||||
<td><div class="plan-price">${{ plan.price / 100 }}</div></td>
|
||||
<td class="controls">
|
||||
<a class="btn" href="javascript:void(0)"
|
||||
ng-class="currentPlan == plan ? 'btn-primary' : 'btn-default'"
|
||||
ng-click="setPlan(plan)">
|
||||
{{ currentPlan == plan ? 'Selected' : 'Choose' }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
5
static/directives/role-group.html
Normal file
|
@ -0,0 +1,5 @@
|
|||
<div class="btn-group btn-group-sm">
|
||||
<button ng-repeat="role in roles"
|
||||
type="button" class="btn" ng-click="setRole(role.id)"
|
||||
ng-class="(currentRole == role.id) ? ('active btn-' + role.kind) : 'btn-default'">{{ role.title }}</button>
|
||||
</div>
|
25
static/directives/signin-form.html
Normal file
|
@ -0,0 +1,25 @@
|
|||
<div class="signin-form-element">
|
||||
<form class="form-signin" ng-submit="signin();">
|
||||
<input type="text" class="form-control input-lg" name="username"
|
||||
placeholder="Username" ng-model="user.username" autofocus>
|
||||
<input type="password" class="form-control input-lg" name="password"
|
||||
placeholder="Password" ng-model="user.password">
|
||||
<button class="btn btn-lg btn-primary btn-block" type="submit">Sign In</button>
|
||||
|
||||
<span class="social-alternate">
|
||||
<i class="fa fa-circle"></i>
|
||||
<span class="inner-text">OR</span>
|
||||
</span>
|
||||
|
||||
<a id="github-signin-link"
|
||||
href="https://github.com/login/oauth/authorize?client_id={{ githubClientId }}&scope=user:email{{ mixpanelDistinctIdClause }}"
|
||||
class="btn btn-primary btn-lg btn-block">
|
||||
<i class="fa fa-github fa-lg"></i> Sign In with GitHub
|
||||
</a>
|
||||
</form>
|
||||
|
||||
<div class="alert alert-danger" ng-show="invalidCredentials">Invalid username or password.</div>
|
||||
<div class="alert alert-danger" ng-show="needsEmailVerification">
|
||||
You must verify your email address before you can sign in.
|
||||
</div>
|
||||
</div>
|
BIN
static/img/org-admin.png
Normal file
After Width: | Height: | Size: 50 KiB |
BIN
static/img/org-repo-admin.png
Normal file
After Width: | Height: | Size: 64 KiB |
BIN
static/img/org-repo-list.png
Normal file
After Width: | Height: | Size: 37 KiB |
BIN
static/img/org-teams.png
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
static/img/quay-icon-stripe.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 61 KiB |
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 80 KiB |
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 99 KiB |
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 194 KiB |
709
static/js/app.js
|
@ -1,15 +1,65 @@
|
|||
function getFirstTextLine(commentString) {
|
||||
if (!commentString) { return ''; }
|
||||
|
||||
var lines = commentString.split('\n');
|
||||
var MARKDOWN_CHARS = {
|
||||
'#': true,
|
||||
'-': true,
|
||||
'>': true,
|
||||
'`': true
|
||||
};
|
||||
|
||||
for (var i = 0; i < lines.length; ++i) {
|
||||
// Skip code lines.
|
||||
if (lines[i].indexOf(' ') == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip empty lines.
|
||||
if ($.trim(lines[i]).length == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip control lines.
|
||||
if (MARKDOWN_CHARS[$.trim(lines[i])[0]]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return getMarkedDown(lines[i]);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getRestUrl(args) {
|
||||
var url = '';
|
||||
for (var i = 0; i < arguments.length; ++i) {
|
||||
if (i > 0) {
|
||||
url += '/';
|
||||
}
|
||||
url += encodeURI(arguments[i])
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function getMarkedDown(string) {
|
||||
return Markdown.getSanitizingConverter().makeHtml(string || '');
|
||||
}
|
||||
|
||||
// Start the application code itself.
|
||||
quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics', 'angulartics.mixpanel', '$strap.directives'], function($provide) {
|
||||
$provide.factory('UserService', ['Restangular', function(Restangular) {
|
||||
quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics', 'angulartics.mixpanel', '$strap.directives', 'ngCookies'], function($provide) {
|
||||
$provide.factory('UserService', ['Restangular', 'PlanService', function(Restangular, PlanService) {
|
||||
var userResponse = {
|
||||
verified: false,
|
||||
anonymous: true,
|
||||
username: null,
|
||||
email: null,
|
||||
askForPassword: false,
|
||||
organizations: []
|
||||
}
|
||||
|
||||
var userService = {}
|
||||
var currentSubscription = null;
|
||||
|
||||
userService.load = function() {
|
||||
var userFetch = Restangular.one('user/');
|
||||
|
@ -30,6 +80,18 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
|
|||
});
|
||||
};
|
||||
|
||||
userService.resetCurrentSubscription = function() {
|
||||
currentSubscription = null;
|
||||
};
|
||||
|
||||
userService.getCurrentSubscription = function(callback, failure) {
|
||||
if (currentSubscription) { callback(currentSubscription); }
|
||||
PlanService.getSubscription(null, function(sub) {
|
||||
currentSubscription = sub;
|
||||
callback(sub);
|
||||
}, failure);
|
||||
};
|
||||
|
||||
userService.currentUser = function() {
|
||||
return userResponse;
|
||||
}
|
||||
|
@ -55,93 +117,134 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
|
|||
}]);
|
||||
|
||||
$provide.factory('PlanService', ['Restangular', 'KeyService', function(Restangular, KeyService) {
|
||||
var plans = [
|
||||
{
|
||||
title: 'Open Source',
|
||||
price: 0,
|
||||
privateRepos: 0,
|
||||
stripeId: 'free',
|
||||
audience: 'Share with the world',
|
||||
},
|
||||
{
|
||||
title: 'Micro',
|
||||
price: 700,
|
||||
privateRepos: 5,
|
||||
stripeId: 'micro',
|
||||
audience: 'For smaller teams',
|
||||
},
|
||||
{
|
||||
title: 'Basic',
|
||||
price: 1200,
|
||||
privateRepos: 10,
|
||||
stripeId: 'small',
|
||||
audience: 'For your basic team',
|
||||
},
|
||||
{
|
||||
title: 'Medium',
|
||||
price: 2200,
|
||||
privateRepos: 20,
|
||||
stripeId: 'medium',
|
||||
audience: 'For medium-sized teams',
|
||||
},
|
||||
];
|
||||
|
||||
var plans = null;
|
||||
var planDict = {};
|
||||
var i;
|
||||
for(i = 0; i < plans.length; i++) {
|
||||
planDict[plans[i].stripeId] = plans[i];
|
||||
}
|
||||
|
||||
var planService = {}
|
||||
|
||||
planService.planList = function() {
|
||||
return plans;
|
||||
};
|
||||
|
||||
planService.getPlan = function(planId) {
|
||||
return planDict[planId];
|
||||
};
|
||||
|
||||
planService.getMinimumPlan = function(privateCount) {
|
||||
for (var i = 0; i < plans.length; i++) {
|
||||
var plan = plans[i];
|
||||
if (plan.privateRepos >= privateCount) {
|
||||
return plan;
|
||||
}
|
||||
planService.verifyLoaded = function(callback) {
|
||||
if (plans) {
|
||||
callback(plans);
|
||||
return;
|
||||
}
|
||||
|
||||
return null;
|
||||
var getPlans = Restangular.one('plans');
|
||||
getPlans.get().then(function(data) {
|
||||
var i = 0;
|
||||
for(i = 0; i < data.user.length; i++) {
|
||||
planDict[data.user[i].stripeId] = data.user[i];
|
||||
}
|
||||
for(i = 0; i < data.business.length; i++) {
|
||||
planDict[data.business[i].stripeId] = data.business[i];
|
||||
}
|
||||
plans = data;
|
||||
callback(plans);
|
||||
}, function() { callback([]); });
|
||||
};
|
||||
|
||||
planService.showSubscribeDialog = function($scope, planId, started, success, failed) {
|
||||
var submitToken = function(token) {
|
||||
$scope.$apply(function() {
|
||||
started();
|
||||
});
|
||||
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.getPlans = function(callback) {
|
||||
planService.verifyLoaded(callback);
|
||||
};
|
||||
|
||||
planService.getPlan = function(planId, callback) {
|
||||
planService.verifyLoaded(function() {
|
||||
if (planDict[planId]) {
|
||||
callback(planDict[planId]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
planService.getMinimumPlan = function(privateCount, isBusiness, callback) {
|
||||
planService.verifyLoaded(function() {
|
||||
var planSource = plans.user;
|
||||
if (isBusiness) {
|
||||
planSource = plans.business;
|
||||
}
|
||||
|
||||
for (var i = 0; i < planSource.length; i++) {
|
||||
var plan = planSource[i];
|
||||
if (plan.privateRepos >= privateCount) {
|
||||
callback(plan);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
callback(null);
|
||||
});
|
||||
};
|
||||
|
||||
planService.getSubscription = function(organization, success, failure) {
|
||||
var url = planService.getSubscriptionUrl(organization);
|
||||
var getSubscription = Restangular.one(url);
|
||||
getSubscription.get().then(success, failure);
|
||||
};
|
||||
|
||||
planService.getSubscriptionUrl = function(orgname) {
|
||||
return orgname ? getRestUrl('organization', orgname, 'plan') : 'user/plan';
|
||||
};
|
||||
|
||||
planService.setSubscription = function(orgname, planId, success, failure, opt_token) {
|
||||
var subscriptionDetails = {
|
||||
plan: planId
|
||||
};
|
||||
|
||||
if (opt_token) {
|
||||
subscriptionDetails['token'] = opt_token.id;
|
||||
}
|
||||
|
||||
var url = planService.getSubscriptionUrl(orgname);
|
||||
var createSubscriptionRequest = Restangular.one(url);
|
||||
createSubscriptionRequest.customPUT(subscriptionDetails).then(success, failure);
|
||||
};
|
||||
|
||||
planService.changePlan = function($scope, orgname, planId, hasExistingSubscription, started, success, failure) {
|
||||
if (!hasExistingSubscription) {
|
||||
planService.showSubscribeDialog($scope, orgname, planId, started, success, failure);
|
||||
return;
|
||||
}
|
||||
|
||||
started();
|
||||
planService.setSubscription(orgname, planId, success, failure);
|
||||
};
|
||||
|
||||
planService.showSubscribeDialog = function($scope, orgname, planId, started, success, failure) {
|
||||
var submitToken = function(token) {
|
||||
mixpanel.track('plan_subscribe');
|
||||
|
||||
var subscriptionDetails = {
|
||||
token: token.id,
|
||||
plan: planId,
|
||||
};
|
||||
|
||||
var createSubscriptionRequest = Restangular.one('user/plan');
|
||||
$scope.$apply(function() {
|
||||
createSubscriptionRequest.customPUT(subscriptionDetails).then(success, failed);
|
||||
started();
|
||||
planService.setSubscription(orgname, planId, success, failure);
|
||||
});
|
||||
};
|
||||
|
||||
var planDetails = planService.getPlan(planId)
|
||||
StripeCheckout.open({
|
||||
key: KeyService.stripePublishableKey,
|
||||
address: false, // TODO change to true
|
||||
amount: planDetails.price,
|
||||
currency: 'usd',
|
||||
name: 'Quay ' + planDetails.title + ' Subscription',
|
||||
description: 'Up to ' + planDetails.privateRepos + ' private repositories',
|
||||
panelLabel: 'Subscribe',
|
||||
token: submitToken
|
||||
planService.getPlan(planId, function(planDetails) {
|
||||
StripeCheckout.open({
|
||||
key: KeyService.stripePublishableKey,
|
||||
address: false,
|
||||
amount: planDetails.price,
|
||||
currency: 'usd',
|
||||
name: 'Quay ' + planDetails.title + ' Subscription',
|
||||
description: 'Up to ' + planDetails.privateRepos + ' private repositories',
|
||||
panelLabel: 'Subscribe',
|
||||
token: submitToken,
|
||||
image: 'static/img/quay-icon-stripe.png'
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -194,12 +297,19 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
|
|||
when('/repository/:namespace/:name/image/:image', {templateUrl: '/static/partials/image-view.html', controller: ImageViewCtrl}).
|
||||
when('/repository/:namespace/:name/admin', {templateUrl: '/static/partials/repo-admin.html', controller:RepoAdminCtrl}).
|
||||
when('/repository/', {title: 'Repositories', templateUrl: '/static/partials/repo-list.html', controller: RepoListCtrl}).
|
||||
when('/user/', {title: 'User Admin', templateUrl: '/static/partials/user-admin.html', controller: UserAdminCtrl}).
|
||||
when('/guide/', {title: 'User Guide', templateUrl: '/static/partials/guide.html', controller: GuideCtrl}).
|
||||
when('/user/', {title: 'Account Settings', templateUrl: '/static/partials/user-admin.html', controller: UserAdminCtrl}).
|
||||
when('/guide/', {title: 'Guide', templateUrl: '/static/partials/guide.html', controller: GuideCtrl}).
|
||||
when('/plans/', {title: 'Plans and Pricing', templateUrl: '/static/partials/plans.html', controller: PlansCtrl}).
|
||||
when('/signin/', {title: 'Signin', templateUrl: '/static/partials/signin.html', controller: SigninCtrl}).
|
||||
when('/signin/', {title: 'Sign In', templateUrl: '/static/partials/signin.html', controller: SigninCtrl}).
|
||||
when('/new/', {title: 'Create new repository', templateUrl: '/static/partials/new-repo.html', controller: NewRepoCtrl}).
|
||||
|
||||
when('/organizations/', {title: 'Organizations', templateUrl: '/static/partials/organizations.html', controller: OrgsCtrl}).
|
||||
when('/organizations/new/', {title: 'New Organization', templateUrl: '/static/partials/new-organization.html', controller: NewOrgCtrl}).
|
||||
|
||||
when('/organization/:orgname', {templateUrl: '/static/partials/org-view.html', controller: OrgViewCtrl}).
|
||||
when('/organization/:orgname/admin', {templateUrl: '/static/partials/org-admin.html', controller: OrgAdminCtrl}).
|
||||
when('/organization/:orgname/teams/:teamname', {templateUrl: '/static/partials/team-view.html', controller: TeamViewCtrl}).
|
||||
|
||||
when('/v1/', {title: 'Activation information', templateUrl: '/static/partials/v1-page.html', controller: V1Ctrl}).
|
||||
|
||||
when('/', {title: 'Hosted Private Docker Registry', templateUrl: '/static/partials/landing.html', controller: LandingCtrl}).
|
||||
|
@ -209,6 +319,31 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
|
|||
RestangularProvider.setBaseUrl('/api/');
|
||||
});
|
||||
|
||||
|
||||
quayApp.directive('markdownView', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/markdown-view.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'content': '=content',
|
||||
'firstLineOnly': '=firstLineOnly'
|
||||
},
|
||||
controller: function($scope, $element) {
|
||||
$scope.getMarkedDown = function(content, firstLineOnly) {
|
||||
if (firstLineOnly) {
|
||||
content = getFirstTextLine(content);
|
||||
}
|
||||
return getMarkedDown(content);
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
||||
|
||||
|
||||
quayApp.directive('repoCircle', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
|
@ -226,6 +361,415 @@ quayApp.directive('repoCircle', function () {
|
|||
});
|
||||
|
||||
|
||||
quayApp.directive('signinForm', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/signin-form.html',
|
||||
replace: false,
|
||||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'redirectUrl': '=redirectUrl'
|
||||
},
|
||||
controller: function($scope, $location, $timeout, Restangular, KeyService, UserService) {
|
||||
$scope.githubClientId = KeyService.githubClientId;
|
||||
|
||||
var appendMixpanelId = function() {
|
||||
if (mixpanel.get_distinct_id !== undefined) {
|
||||
$scope.mixpanelDistinctIdClause = "&state=" + mixpanel.get_distinct_id();
|
||||
} else {
|
||||
// Mixpanel not yet loaded, try again later
|
||||
$timeout(appendMixpanelId, 200);
|
||||
}
|
||||
};
|
||||
|
||||
appendMixpanelId();
|
||||
|
||||
$scope.signin = function() {
|
||||
var signinPost = Restangular.one('signin');
|
||||
signinPost.customPOST($scope.user).then(function() {
|
||||
$scope.needsEmailVerification = false;
|
||||
$scope.invalidCredentials = false;
|
||||
|
||||
// Redirect to the specified page or the landing page
|
||||
UserService.load();
|
||||
$location.path($scope.redirectUrl ? $scope.redirectUrl : '/');
|
||||
}, function(result) {
|
||||
$scope.needsEmailVerification = result.data.needsEmailVerification;
|
||||
$scope.invalidCredentials = result.data.invalidCredentials;
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
||||
|
||||
|
||||
quayApp.directive('plansTable', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/plans-table.html',
|
||||
replace: false,
|
||||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'plans': '=plans',
|
||||
'currentPlan': '=currentPlan'
|
||||
},
|
||||
controller: function($scope, $element) {
|
||||
$scope.setPlan = function(plan) {
|
||||
$scope.currentPlan = plan;
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
||||
|
||||
|
||||
quayApp.directive('organizationHeader', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/organization-header.html',
|
||||
replace: false,
|
||||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'organization': '=organization',
|
||||
'teamName': '=teamName'
|
||||
},
|
||||
controller: function($scope, $element) {
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
||||
|
||||
|
||||
quayApp.directive('markdownInput', function () {
|
||||
var counter = 0;
|
||||
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/markdown-input.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'content': '=content',
|
||||
'canWrite': '=canWrite',
|
||||
'contentChanged': '=contentChanged',
|
||||
'fieldTitle': '=fieldTitle'
|
||||
},
|
||||
controller: function($scope, $element) {
|
||||
var elm = $element[0];
|
||||
|
||||
$scope.id = (counter++);
|
||||
|
||||
$scope.editContent = function() {
|
||||
if (!$scope.canWrite) { return; }
|
||||
|
||||
if (!$scope.markdownDescriptionEditor) {
|
||||
var converter = Markdown.getSanitizingConverter();
|
||||
var editor = new Markdown.Editor(converter, '-description-' + $scope.id);
|
||||
editor.run();
|
||||
$scope.markdownDescriptionEditor = editor;
|
||||
}
|
||||
|
||||
$('#wmd-input-description-' + $scope.id)[0].value = $scope.content;
|
||||
$(elm).find('.modal').modal({});
|
||||
};
|
||||
|
||||
$scope.saveContent = function() {
|
||||
$scope.content = $('#wmd-input-description-' + $scope.id)[0].value;
|
||||
$(elm).find('.modal').modal('hide');
|
||||
|
||||
if ($scope.contentChanged) {
|
||||
$scope.contentChanged($scope.content);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
||||
|
||||
|
||||
quayApp.directive('entitySearch', function () {
|
||||
var number = 0;
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/entity-search.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'organization': '=organization',
|
||||
'inputTitle': '=inputTitle',
|
||||
'entitySelected': '=entitySelected'
|
||||
},
|
||||
controller: function($scope, $element) {
|
||||
if (!$scope.entitySelected) { return; }
|
||||
|
||||
number++;
|
||||
|
||||
var input = $element[0].firstChild;
|
||||
$scope.organization = $scope.organization || '';
|
||||
$(input).typeahead({
|
||||
name: 'entities' + number,
|
||||
remote: {
|
||||
url: '/api/entities/%QUERY',
|
||||
replace: function (url, uriEncodedQuery) {
|
||||
url = url.replace('%QUERY', uriEncodedQuery);
|
||||
if ($scope.organization) {
|
||||
url += '?organization=' + encodeURIComponent($scope.organization);
|
||||
}
|
||||
return url;
|
||||
},
|
||||
filter: function(data) {
|
||||
var datums = [];
|
||||
for (var i = 0; i < data.results.length; ++i) {
|
||||
var entity = data.results[i];
|
||||
datums.push({
|
||||
'value': entity.name,
|
||||
'tokens': [entity.name],
|
||||
'entity': entity
|
||||
});
|
||||
}
|
||||
return datums;
|
||||
}
|
||||
},
|
||||
template: function (datum) {
|
||||
template = '<div class="entity-mini-listing">';
|
||||
if (datum.entity.kind == 'user') {
|
||||
template += '<i class="fa fa-user fa-lg"></i>';
|
||||
} else if (datum.entity.kind == 'team') {
|
||||
template += '<i class="fa fa-group fa-lg"></i>';
|
||||
}
|
||||
template += '<span class="name">' + datum.value + '</span>';
|
||||
|
||||
if (datum.entity.is_org_member !== undefined && !datum.entity.is_org_member) {
|
||||
template += '<div class="alert-warning warning">This user is outside your organization</div>';
|
||||
}
|
||||
|
||||
template += '</div>';
|
||||
return template;
|
||||
},
|
||||
});
|
||||
|
||||
$(input).on('typeahead:selected', function(e, datum) {
|
||||
$(input).typeahead('setQuery', '');
|
||||
$scope.entitySelected(datum.entity);
|
||||
});
|
||||
|
||||
$scope.$watch('inputTitle', function(title) {
|
||||
input.setAttribute('placeholder', title);
|
||||
});
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
||||
|
||||
|
||||
quayApp.directive('roleGroup', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/role-group.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'roles': '=roles',
|
||||
'currentRole': '=currentRole',
|
||||
'roleChanged': '&roleChanged'
|
||||
},
|
||||
controller: function($scope, $element) {
|
||||
$scope.setRole = function(role) {
|
||||
if ($scope.currentRole == role) { return; }
|
||||
if ($scope.roleChanged) {
|
||||
$scope.roleChanged({'role': role});
|
||||
} else {
|
||||
$scope.currentRole = role;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
||||
|
||||
|
||||
quayApp.directive('planManager', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/plan-manager.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'user': '=user',
|
||||
'organization': '=organization',
|
||||
'readyForPlan': '&readyForPlan'
|
||||
},
|
||||
controller: function($scope, $element, PlanService, Restangular) {
|
||||
var hasSubscription = false;
|
||||
|
||||
$scope.getActiveSubClass = function() {
|
||||
return 'active';
|
||||
};
|
||||
|
||||
$scope.changeSubscription = function(planId) {
|
||||
if ($scope.planChanging) { return; }
|
||||
|
||||
PlanService.changePlan($scope, $scope.organization, planId, hasSubscription, function() {
|
||||
// Started.
|
||||
$scope.planChanging = true;
|
||||
}, function(sub) {
|
||||
// Success.
|
||||
subscribedToPlan(sub);
|
||||
}, function() {
|
||||
// Failure.
|
||||
$scope.planChanging = false;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.cancelSubscription = function() {
|
||||
$scope.changeSubscription(getFreePlan());
|
||||
};
|
||||
|
||||
var subscribedToPlan = function(sub) {
|
||||
$scope.subscription = sub;
|
||||
|
||||
if (sub.plan != getFreePlan()) {
|
||||
hasSubscription = true;
|
||||
}
|
||||
|
||||
PlanService.getPlan(sub.plan, function(subscribedPlan) {
|
||||
$scope.subscribedPlan = subscribedPlan;
|
||||
$scope.planUsagePercent = sub.usedPrivateRepos * 100 / $scope.subscribedPlan.privateRepos;
|
||||
|
||||
if (sub.usedPrivateRepos > $scope.subscribedPlan.privateRepos) {
|
||||
$scope.limit = 'over';
|
||||
} else if (sub.usedPrivateRepos == $scope.subscribedPlan.privateRepos) {
|
||||
$scope.limit = 'at';
|
||||
} else if (sub.usedPrivateRepos >= $scope.subscribedPlan.privateRepos * 0.7) {
|
||||
$scope.limit = 'near';
|
||||
} else {
|
||||
$scope.limit = 'none';
|
||||
}
|
||||
|
||||
if (!$scope.chart) {
|
||||
$scope.chart = new RepositoryUsageChart();
|
||||
$scope.chart.draw('repository-usage-chart');
|
||||
}
|
||||
|
||||
$scope.chart.update(sub.usedPrivateRepos || 0, $scope.subscribedPlan.privateRepos || 0);
|
||||
|
||||
$scope.planChanging = false;
|
||||
$scope.planLoading = false;
|
||||
});
|
||||
};
|
||||
|
||||
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() {
|
||||
$scope.planLoading = true;
|
||||
if (!$scope.plans) { return; }
|
||||
|
||||
PlanService.getSubscription($scope.organization, subscribedToPlan, function() {
|
||||
// User/Organization has no subscription.
|
||||
subscribedToPlan({ 'plan': getFreePlan() });
|
||||
});
|
||||
};
|
||||
|
||||
var loadPlans = function() {
|
||||
if ($scope.plans || $scope.loadingPlans) { return; }
|
||||
if (!$scope.user && !$scope.organization) { return; }
|
||||
|
||||
$scope.loadingPlans = true;
|
||||
PlanService.getPlans(function(plans) {
|
||||
$scope.plans = plans[$scope.organization ? 'business' : 'user'];
|
||||
update();
|
||||
|
||||
if ($scope.readyForPlan) {
|
||||
var planRequested = $scope.readyForPlan();
|
||||
if (planRequested && planRequested != getFreePlan()) {
|
||||
$scope.changeSubscription(planRequested);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Start the initial download.
|
||||
$scope.planLoading = true;
|
||||
loadPlans();
|
||||
|
||||
$scope.$watch('organization', loadPlans);
|
||||
$scope.$watch('user', loadPlans);
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
||||
|
||||
|
||||
|
||||
quayApp.directive('namespaceSelector', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/namespace-selector.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'user': '=user',
|
||||
'namespace': '=namespace',
|
||||
'requireCreate': '=requireCreate'
|
||||
},
|
||||
controller: function($scope, $element, $routeParams, $cookieStore) {
|
||||
$scope.namespaces = {};
|
||||
|
||||
$scope.initialize = function(user) {
|
||||
var namespaces = {};
|
||||
namespaces[user.username] = user;
|
||||
if (user.organizations) {
|
||||
for (var i = 0; i < user.organizations.length; ++i) {
|
||||
namespaces[user.organizations[i].name] = user.organizations[i];
|
||||
}
|
||||
}
|
||||
|
||||
var initialNamespace = $routeParams['namespace'] || $cookieStore.get('quay.currentnamespace') || $scope.user.username;
|
||||
$scope.namespaces = namespaces;
|
||||
$scope.setNamespace($scope.namespaces[initialNamespace]);
|
||||
};
|
||||
|
||||
$scope.setNamespace = function(namespaceObj) {
|
||||
if (!namespaceObj) {
|
||||
namespaceObj = $scope.namespaces[$scope.user.username];
|
||||
}
|
||||
|
||||
if ($scope.requireCreate && !namespaceObj.can_create_repo) {
|
||||
namespaceObj = $scope.namespaces[$scope.user.username];
|
||||
}
|
||||
|
||||
var newNamespace = namespaceObj.name || namespaceObj.username;
|
||||
$scope.namespaceObj = namespaceObj;
|
||||
$scope.namespace = newNamespace;
|
||||
$cookieStore.put('quay.currentnamespace', newNamespace);
|
||||
};
|
||||
|
||||
$scope.$watch('user', function(user) {
|
||||
$scope.user = user;
|
||||
$scope.initialize(user);
|
||||
});
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
||||
|
||||
|
||||
quayApp.directive('buildStatus', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
|
@ -292,6 +836,15 @@ quayApp.directive('buildStatus', function () {
|
|||
return directiveDefinitionObject;
|
||||
});
|
||||
|
||||
// Note: ngBlur is not yet in Angular stable, so we add it manaully here.
|
||||
quayApp.directive('ngBlur', function() {
|
||||
return function( scope, elem, attrs ) {
|
||||
elem.bind('blur', function() {
|
||||
scope.$apply(attrs.ngBlur);
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
quayApp.run(['$location', '$rootScope', function($location, $rootScope) {
|
||||
$rootScope.$on('$routeChangeSuccess', function (event, current, previous) {
|
||||
if (current.$$route.title) {
|
||||
|
|
|
@ -137,6 +137,10 @@ ImageHistoryTree.prototype.draw = function(container) {
|
|||
.direction('e')
|
||||
.html(function(d) {
|
||||
var html = '';
|
||||
if (d.virtual) {
|
||||
return d.name;
|
||||
}
|
||||
|
||||
if (d.collapsed) {
|
||||
for (var i = 1; i < d.encountered.length; ++i) {
|
||||
html += '<span>' + d.encountered[i].image.id.substr(0, 12) + '</span>';
|
||||
|
@ -272,6 +276,7 @@ ImageHistoryTree.prototype.buildRoot_ = function() {
|
|||
|
||||
// For each node, attach it to its immediate parent. If there is no immediate parent,
|
||||
// then the node is the root.
|
||||
var roots = [];
|
||||
for (var i = 0; i < this.images_.length; ++i) {
|
||||
var image = this.images_[i];
|
||||
var imageNode = imageByDBID[image.dbid];
|
||||
|
@ -283,10 +288,22 @@ ImageHistoryTree.prototype.buildRoot_ = function() {
|
|||
imageNode.parent = parent;
|
||||
parent.children.push(imageNode);
|
||||
} else {
|
||||
formatted = imageNode;
|
||||
roots.push(imageNode);
|
||||
}
|
||||
}
|
||||
|
||||
// If there are multiple root nodes, then there is at least one branch without shared
|
||||
// ancestry and we use the virtual node. Otherwise, we use the root node found.
|
||||
var root = {
|
||||
'name': '',
|
||||
'children': roots,
|
||||
'virtual': true
|
||||
};
|
||||
|
||||
if (roots.length == 1) {
|
||||
root = roots[0];
|
||||
}
|
||||
|
||||
// Determine the maximum number of nodes at a particular level. This is used to size
|
||||
// the width of the tree properly.
|
||||
var maxChildCount = 0;
|
||||
|
@ -300,14 +317,14 @@ ImageHistoryTree.prototype.buildRoot_ = function() {
|
|||
// section. We only do this if the max width is > 1 (since for a single width tree, no long
|
||||
// chain will hide a branch).
|
||||
if (maxChildCount > 1) {
|
||||
this.collapseNodes_(formatted);
|
||||
this.collapseNodes_(root);
|
||||
}
|
||||
|
||||
// Determine the maximum height of the tree.
|
||||
var maxHeight = this.determineMaximumHeight_(formatted);
|
||||
var maxHeight = this.determineMaximumHeight_(root);
|
||||
|
||||
// Finally, set the root node and return.
|
||||
this.root_ = formatted;
|
||||
this.root_ = root;
|
||||
|
||||
return {
|
||||
'maxWidth': maxChildCount + 1,
|
||||
|
@ -566,7 +583,6 @@ ImageHistoryTree.prototype.update_ = function(source) {
|
|||
|
||||
// Translate the foreign object so the tags are under the ID.
|
||||
fo.attr("transform", function(d, i) {
|
||||
bbox = this.getBBox()
|
||||
return "translate(" + [-130, 0 ] + ")";
|
||||
});
|
||||
|
||||
|
@ -594,6 +610,9 @@ ImageHistoryTree.prototype.update_ = function(source) {
|
|||
if (d.collapsed) {
|
||||
return 'collapsed';
|
||||
}
|
||||
if (d.virtual) {
|
||||
return 'virtual';
|
||||
}
|
||||
if (!currentImage) {
|
||||
return '';
|
||||
}
|
||||
|
@ -1130,4 +1149,116 @@ ImageFileChangeTree.prototype.toggle_ = function(d) {
|
|||
d.children = d._children;
|
||||
d._children = null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* Based off of http://bl.ocks.org/mbostock/1346410
|
||||
*/
|
||||
function RepositoryUsageChart() {
|
||||
this.total_ = null;
|
||||
this.count_ = null;
|
||||
this.drawn_ = false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Updates the chart with the given count and total of number of repositories.
|
||||
*/
|
||||
RepositoryUsageChart.prototype.update = function(count, total) {
|
||||
if (!this.g_) { return; }
|
||||
this.total_ = total;
|
||||
this.count_ = count;
|
||||
this.drawInternal_();
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Conducts the actual draw or update (if applicable).
|
||||
*/
|
||||
RepositoryUsageChart.prototype.drawInternal_ = function() {
|
||||
// If the total is null, then we have not yet set the proper counts.
|
||||
if (this.total_ === null) { return; }
|
||||
|
||||
var duration = 750;
|
||||
|
||||
var arc = this.arc_;
|
||||
var pie = this.pie_;
|
||||
var arcTween = this.arcTween_;
|
||||
|
||||
var color = d3.scale.category20();
|
||||
var count = this.count_;
|
||||
var total = this.total_;
|
||||
|
||||
var data = [Math.max(count, 1), Math.max(0, total - count)];
|
||||
|
||||
var arcTween = function(a) {
|
||||
var i = d3.interpolate(this._current, a);
|
||||
this._current = i(0);
|
||||
return function(t) {
|
||||
return arc(i(t));
|
||||
};
|
||||
};
|
||||
|
||||
if (!this.drawn_) {
|
||||
var text = this.g_.append("svg:text")
|
||||
.attr("dy", 10)
|
||||
.attr("dx", 0)
|
||||
.attr('dominant-baseline', 'auto')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('class', 'count-text')
|
||||
.text(this.count_ + ' / ' + this.total_);
|
||||
|
||||
var path = this.g_.datum(data).selectAll("path")
|
||||
.data(pie)
|
||||
.enter().append("path")
|
||||
.attr("fill", function(d, i) { return color(i); })
|
||||
.attr("class", function(d, i) { return 'arc-' + i; })
|
||||
.attr("d", arc)
|
||||
.each(function(d) { this._current = d; }); // store the initial angles
|
||||
|
||||
this.path_ = path;
|
||||
this.text_ = text;
|
||||
} else {
|
||||
pie.value(function(d, i) { return data[i]; }); // change the value function
|
||||
this.path_ = this.path_.data(pie); // compute the new angles
|
||||
this.path_.transition().duration(duration).attrTween("d", arcTween); // redraw the arcs
|
||||
|
||||
// Update the text.
|
||||
this.text_.text(this.count_ + ' / ' + this.total_);
|
||||
}
|
||||
|
||||
this.drawn_ = true;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Draws the chart in the given container.
|
||||
*/
|
||||
RepositoryUsageChart.prototype.draw = function(container) {
|
||||
var cw = 200;
|
||||
var ch = 200;
|
||||
var radius = Math.min(cw, ch) / 2;
|
||||
|
||||
var pie = d3.layout.pie().sort(null);
|
||||
|
||||
var arc = d3.svg.arc()
|
||||
.innerRadius(radius - 50)
|
||||
.outerRadius(radius - 25);
|
||||
|
||||
var svg = d3.select("#" + container).append("svg:svg")
|
||||
.attr("width", cw)
|
||||
.attr("height", ch);
|
||||
|
||||
var g = svg.append("g")
|
||||
.attr("transform", "translate(" + cw / 2 + "," + ch / 2 + ")");
|
||||
|
||||
this.svg_ = svg;
|
||||
this.g_ = g;
|
||||
this.pie_ = pie;
|
||||
this.arc_ = arc;
|
||||
this.width_ = cw;
|
||||
this.drawInternal_();
|
||||
};
|
7
static/lib/angular-cookies.min.js
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
AngularJS v1.2.0-ed8640b
|
||||
(c) 2010-2012 Google, Inc. http://angularjs.org
|
||||
License: MIT
|
||||
*/
|
||||
(function(p,f,n){'use strict';f.module("ngCookies",["ng"]).factory("$cookies",["$rootScope","$browser",function(d,b){var c={},g={},h,k=!1,l=f.copy,m=f.isUndefined;b.addPollFn(function(){var a=b.cookies();h!=a&&(h=a,l(a,g),l(a,c),k&&d.$apply())})();k=!0;d.$watch(function(){var a,e,d;for(a in g)m(c[a])&&b.cookies(a,n);for(a in c)(e=c[a],f.isString(e))?e!==g[a]&&(b.cookies(a,e),d=!0):f.isDefined(g[a])?c[a]=g[a]:delete c[a];if(d)for(a in e=b.cookies(),c)c[a]!==e[a]&&(m(e[a])?delete c[a]:c[a]=e[a])});
|
||||
return c}]).factory("$cookieStore",["$cookies",function(d){return{get:function(b){return(b=d[b])?f.fromJson(b):b},put:function(b,c){d[b]=f.toJson(c)},remove:function(b){delete d[b]}}}])})(window,window.angular);
|
2
static/lib/angular-strap.min.js
vendored
3
static/partials/create-team-dialog.html
Normal file
|
@ -0,0 +1,3 @@
|
|||
<form name="newteamform" ng-submit="createTeam(); hide()" novalidate>
|
||||
<input id="create-team-box" type="text form-control" placeholder="Team Name" ng-blur="hide()" ng-pattern="/^[a-zA-Z][a-zA-Z0-9]+$/" ng-model="newTeamName" ng-trim="false" ng-minlength="2" required>
|
||||
</form>
|
|
@ -1,7 +1,7 @@
|
|||
<div class="container ready-indicator" data-status="{{ status }}">
|
||||
<div class="alert alert-warning">Warning: Quay requires docker version 0.6.2 or higher to work</div>
|
||||
|
||||
<h2>User guide</h2>
|
||||
<h2>User Guide</h2>
|
||||
<div class="user-guide container">
|
||||
|
||||
<h3>Pulling a repository from Quay</h3>
|
||||
|
|
|
@ -15,8 +15,9 @@
|
|||
<div class="collapse navbar-collapse navbar-ex1-collapse">
|
||||
<ul class="nav navbar-nav">
|
||||
<li><a ng-href="/repository/" target="{{ appLinkTarget() }}">Repositories</a></li>
|
||||
<li><a ng-href="/guide/" target="{{ appLinkTarget() }}">User Guide</a></li>
|
||||
<li><a ng-href="/plans/" target="{{ appLinkTarget() }}">Plans & Pricing</a></li>
|
||||
<li><a ng-href="/guide/" target="{{ appLinkTarget() }}">Guide</a></li>
|
||||
<li><a ng-href="/plans/" target="{{ appLinkTarget() }}">Pricing</a></li>
|
||||
<li><a ng-href="/organizations/" target="{{ appLinkTarget() }}">Organizations</a></li>
|
||||
</ul>
|
||||
|
||||
|
||||
|
@ -28,7 +29,7 @@
|
|||
</form>
|
||||
|
||||
<span class="navbar-left user-tools" ng-show="!user.anonymous">
|
||||
<a href="/new/"><i class="fa fa-upload user-tool" title="Create new repository"></i></a>
|
||||
<a href="/new/"><i class="fa fa-upload user-tool" bs-tooltip="tooltip.title" data-placement="bottom" title="Create new repository"></i></a>
|
||||
</span>
|
||||
|
||||
<li class="dropdown" ng-switch-when="false">
|
||||
|
@ -45,6 +46,7 @@
|
|||
<span class="badge user-notification" ng-show="user.askForPassword">1</span>
|
||||
</a>
|
||||
</li>
|
||||
<li><a ng-href="/organizations/" target="{{ appLinkTarget() }}">Organizations</a></li>
|
||||
<li><a href="javascript:void(0)" ng-click="signout()">Sign out</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
|
|
@ -20,7 +20,9 @@
|
|||
</div>
|
||||
|
||||
<!-- Comment -->
|
||||
<blockquote ng-show="image.comment" ng-bind-html-unsafe="getMarkedDown(image.comment)"></blockquote>
|
||||
<blockquote ng-show="image.comment">
|
||||
<span class="markdown-view" content="image.comment"></span>
|
||||
</blockquote>
|
||||
|
||||
<!-- Information -->
|
||||
<dl class="dl-normal">
|
||||
|
|
|
@ -12,22 +12,22 @@
|
|||
<div ng-show="loadingmyrepos">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
<span class="namespace-selector" user="user" namespace="namespace" ng-show="!loadingmyrepos && user.organizations"></span>
|
||||
<div ng-show="!loadingmyrepos && myrepos.length > 0">
|
||||
<h2>Your Top Repositories</h2>
|
||||
<h2>Top Repositories</h2>
|
||||
<div class="repo-listing" ng-repeat="repository in myrepos">
|
||||
<span class="repo-circle no-background" repo="repository"></span>
|
||||
<a ng-href="/repository/{{ repository.namespace }}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
|
||||
<div class="description" ng-bind-html-unsafe="getCommentFirstLine(repository.description)"></div>
|
||||
<div class="markdown-view description" content="repository.description" first-line-only="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-show="!loadingmyrepos && myrepos.length == 0">
|
||||
<div class="sub-message">
|
||||
You don't have any <b>private</b> repositories yet!
|
||||
|
||||
<div class="sub-message" style="margin-top: 20px">
|
||||
<span ng-show="namespace != user.username">You don't have access to any repositories in this organization yet.</span>
|
||||
<span ng-show="namespace == user.username">You don't have any repositories yet!</span>
|
||||
<div class="options">
|
||||
<div class="option"><a href="/guide">Learn how to create a repository</a></div>
|
||||
<div class="or"><span>or</span></div>
|
||||
<div class="option"><a href="/repository">Browse the public repositories</a></div>
|
||||
<a class="btn btn-primary" href="/repository/">Browse all repositories</a>
|
||||
<a class="btn btn-success" href="/new/" ng-show="canCreateRepo(namespace)">Create a new repository</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
102
static/partials/new-organization.html
Normal file
|
@ -0,0 +1,102 @@
|
|||
<div class="loading" ng-show="loading || creating">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
|
||||
<div class="container create-org" ng-show="!loading && !creating">
|
||||
|
||||
<div class="row header-row">
|
||||
<div class="col-md-8 col-md-offset-1">
|
||||
<h2>Create Organization</h2>
|
||||
|
||||
<div class="steps-container" ng-show="false">
|
||||
<ul class="steps">
|
||||
<li class="step" ng-class="!user || user.anonymous ? 'active' : ''">
|
||||
<i class="fa fa-sign-in"></i>
|
||||
<span class="title">Login with an account</span>
|
||||
</li>
|
||||
<li class="step" ng-class="!user.anonymous && !created ? 'active' : ''">
|
||||
<i class="fa fa-gear"></i>
|
||||
<span class="title">Setup your organization</span>
|
||||
</li>
|
||||
<li class="step" ng-class="!user.anonymous && created ? 'active' : ''">
|
||||
<i class="fa fa-group"></i>
|
||||
<span class="title">Create teams</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1 -->
|
||||
<div class="row" ng-show="!user || user.anonymous">
|
||||
<div class="col-md-10 col-md-offset-1 page-description">
|
||||
In order to create a new organization, <b>you must first be signed in</b> as the
|
||||
user that <b>will become an admin</b> for the organization. Please sign-in if
|
||||
you already have an account, or <a href="/">sign up</a> on the landing
|
||||
page to create a new account.
|
||||
</div>
|
||||
<div class="col-sm-6 col-sm-offset-3">
|
||||
<div class="step-container" >
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">Sign In</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="signin-form" redirect-url="'/organizations/new'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="row" ng-show="user && !user.anonymous && !created">
|
||||
<div class="col-md-1"></div>
|
||||
<div class="col-md-8">
|
||||
<div class="step-container">
|
||||
<h3>Setup the new organization</h3>
|
||||
|
||||
<form method="post" name="newOrgForm" id="newOrgForm" ng-submit="createNewOrg()">
|
||||
<div class="form-group">
|
||||
<label for="orgName">Organization Name</label>
|
||||
<input id="orgName" name="orgName" type="text" class="form-control" placeholder="Organization Name"
|
||||
ng-model="org.name" required autofocus data-trigger="manual" data-content="{{ createError }}"
|
||||
data-placement="right">
|
||||
<span class="description">This will also be the namespace for your repositories</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="orgName">Organization Email</label>
|
||||
<input id="orgEmail" name="orgEmail" type="email" class="form-control" placeholder="Organization Email"
|
||||
ng-model="org.email" required>
|
||||
<span class="description">This address must be different from your account's email</span>
|
||||
</div>
|
||||
|
||||
<!-- Plans Table -->
|
||||
<div class="form-group plan-group">
|
||||
<strong>Choose your organization's plan</strong>
|
||||
<div class="plans-table" plans="plans" current-plan="currentPlan"></div>
|
||||
</div>
|
||||
|
||||
<div class="button-bar">
|
||||
<button class="btn btn-large btn-success" type="submit" ng-disabled="newOrgForm.$invalid || !currentPlan">
|
||||
Create Organization
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="row" ng-show="user && !user.anonymous && created">
|
||||
<div class="col-md-1"></div>
|
||||
<div class="col-md-8">
|
||||
<div class="step-container">
|
||||
<h3>Organization Created</h3>
|
||||
<h4><a href="/organization/{{ org.name }}">Manage Teams Now</a></h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -28,17 +28,19 @@
|
|||
<div class="col-md-8">
|
||||
<div class="section">
|
||||
<div class="new-header">
|
||||
<span class="repo-circle no-background" repo="repo"></span>
|
||||
<span style="color: #444;"> {{user.username}}</span> <span style="color: #ccc">/</span> <span class="name-container"><input id="repoName" name="repoName" type="text" class="form-control" placeholder="Repository Name" ng-model="repo.name" required autofocus data-trigger="manual" data-content="{{ createError }}" data-placement="right"></span>
|
||||
<span style="color: #444;">
|
||||
<span class="namespace-selector" user="user" namespace="repo.namespace" require-create="true"></span>
|
||||
<span style="color: #ccc">/</span>
|
||||
<span class="name-container">
|
||||
<input id="repoName" name="repoName" type="text" class="form-control" placeholder="Repository Name" ng-model="repo.name" required autofocus data-trigger="manual" data-content="{{ createError }}" data-placement="right">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<strong>Description:</strong><br>
|
||||
<p class="description lead editable" ng-click="editDescription()">
|
||||
<span class="content" ng-bind-html-unsafe="getMarkedDown(repo.description)"></span>
|
||||
<i class="fa fa-edit"></i>
|
||||
</p>
|
||||
<div class="description markdown-input" content="repo.description" can-write="true"
|
||||
field-title="'repository description'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -68,13 +70,19 @@
|
|||
</div>
|
||||
|
||||
<!-- Payment -->
|
||||
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired">
|
||||
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired && isUserNamespace">
|
||||
<div class="alert alert-warning">
|
||||
In order to make this repository private, you’ll need to upgrade your plan from <b>{{ subscribedPlan.title }}</b> to <b>{{ planRequired.title }}</b>. This will cost $<span>{{ planRequired.price / 100 }}</span>/month.
|
||||
</div>
|
||||
<a class="btn btn-primary" ng-click="upgradePlan()" ng-show="!planChanging">Upgrade now</a>
|
||||
<i class="fa fa-spinner fa-spin fa-3x" ng-show="planChanging"></i>
|
||||
</div>
|
||||
|
||||
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired && !isUserNamespace">
|
||||
<div class="alert alert-warning">
|
||||
This organization has reached its private repository limit. Please contact your administrator.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
67
static/partials/org-admin.html
Normal file
|
@ -0,0 +1,67 @@
|
|||
<div class="loading" ng-show="loading">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
|
||||
<div class="loading" ng-show="!loading && !organization">
|
||||
No matching organization found
|
||||
</div>
|
||||
|
||||
<div class="org-admin container" ng-show="!loading && organization">
|
||||
<div class="organization-header" organization="organization"></div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Side tabs -->
|
||||
<div class="col-md-2">
|
||||
<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><a href="javascript:void(0)" data-toggle="tab" data-target="#members" ng-click="loadMembers()">Members</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="col-md-10">
|
||||
<div class="tab-content">
|
||||
<!-- Plans tab -->
|
||||
<div id="plan" class="tab-pane active">
|
||||
<div class="plan-manager" organization="orgname"></div>
|
||||
</div>
|
||||
|
||||
<!-- Members tab -->
|
||||
<div id="members" class="tab-pane">
|
||||
<i class="fa fa-spinner fa-spin fa-3x" ng-show="membersLoading"></i>
|
||||
|
||||
<div ng-show="!membersLoading">
|
||||
<div class="side-controls">
|
||||
<div class="result-count">
|
||||
Showing {{(membersFound | filter:search | limitTo:50).length}} of {{(membersFound | filter:search).length}} matching members
|
||||
</div>
|
||||
<div class="filter-input">
|
||||
<input id="member-filter" class="form-control" placeholder="Filter Members" type="text" ng-model="search.$">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<th>User</th>
|
||||
<th>Teams</th>
|
||||
</thead>
|
||||
|
||||
<tr ng-repeat="memberInfo in (membersFound | filter:search | limitTo:50)">
|
||||
<td>
|
||||
<i class="fa fa-user"></i>
|
||||
{{ memberInfo.username }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="team-link" ng-repeat="team in memberInfo.teams">
|
||||
<i class="fa fa-group"></i>
|
||||
<a href="/organization/{{ organization.name }}/teams/{{ team }}">{{ team }}</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
86
static/partials/org-view.html
Normal file
|
@ -0,0 +1,86 @@
|
|||
<div class="loading" ng-show="loading">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
|
||||
<div class="loading" ng-show="!loading && !organization">
|
||||
No matching organization found
|
||||
</div>
|
||||
|
||||
<div class="org-view container" ng-show="!loading && organization">
|
||||
<div class="organization-header" organization="organization">
|
||||
<div class="header-buttons" ng-show="organization.is_admin">
|
||||
<button class="btn btn-success" data-trigger="click" bs-popover="'static/partials/create-team-dialog.html'" data-placement="bottom" ng-click="createTeamShown()"><i class="fa fa-group"></i> Create Team</button>
|
||||
<a class="btn btn-default" href="/organization/{{ organization.name }}/admin"><i class="fa fa-gear"></i> Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row hidden-xs">
|
||||
<div class="col-md-4 col-md-offset-8 col-sm-5 col-sm-offset-7 header-col" ng-show="organization.is_admin">
|
||||
Team Permissions
|
||||
<i class="info-icon fa fa-info-circle" data-placement="bottom" data-original-title="" title=""
|
||||
data-content="Global permissions for the team and its members<br><br><dl><dt>Member</dt><dd>Permissions are assigned on a per repository basis</dd><dt>Creator</dt><dd>A team can create its own repositories</dd><dt>Admin</dt><dd>A team has full control of the organization</dd></dl>"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="team-listing" ng-repeat="(name, team) in organization.teams">
|
||||
<div id="team-{{name}}" class="row">
|
||||
<div class="col-sm-7 col-md-8">
|
||||
<div class="team-title">
|
||||
<i class="fa fa-group"></i>
|
||||
<span ng-show="team.can_view">
|
||||
<a href="/organization/{{ organization.name }}/teams/{{ team.name }}">{{ team.name }}</a>
|
||||
</span>
|
||||
<span ng-show="!team.can_view">
|
||||
{{ team.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="team-description markdown-view" content="team.description" first-line-only="true"></div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-5 col-md-4 control-col" ng-show="organization.is_admin">
|
||||
<span class="role-group" current-role="team.role" role-changed="setRole(role, team.name)" roles="teamRoles"></span>
|
||||
<button class="btn btn-sm btn-danger" ng-click="askDeleteTeam(team.name)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="cannotChangeTeamModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Cannot change team</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span ng-show="!roleError">You do not have permission to change properties on teams.</span>
|
||||
<span ng-show="roleError">{{ roleError }}</span>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="confirmdeleteModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Delete Team?</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Are you sure you would like to delete this team? This <b>cannot be undone</b>.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger" ng-click="deleteTeam()">Delete Team</button>
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
129
static/partials/organizations.html
Normal file
|
@ -0,0 +1,129 @@
|
|||
<div class="container org-list">
|
||||
<div class="loading" ng-show="loading">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
|
||||
<div class="button-bar-right">
|
||||
<a href="/organizations/new/" title="Starts the process to create a new organization" bs-tooltip="tooltip.title">
|
||||
<button class="btn btn-success">
|
||||
<i class="fa fa-plus"></i>
|
||||
Create New Organization
|
||||
</button>
|
||||
</a>
|
||||
<a href="/user/?migrate" ng-show="!user.anonymous" title="Starts the process to convert this account into an organization" bs-tooltip="tooltip.title">
|
||||
<button class="btn btn-primary">
|
||||
<i class="fa fa-caret-square-o-right"></i>
|
||||
Convert account
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Organizations -->
|
||||
<div ng-show="user.organizations.length > 0">
|
||||
<h2>Organizations</h2>
|
||||
|
||||
<div class="organization-listing" ng-repeat="organization in user.organizations">
|
||||
<img class="gravatar" src="//www.gravatar.com/avatar/{{ organization.gravatar }}?s=32&d=identicon">
|
||||
<a class="org-title" href="/organization/{{ organization.name }}">{{ organization.name }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Organization Help/Tour -->
|
||||
<div class="product-tour" ng-show="!user.organizations || user.organizations.length == 0">
|
||||
|
||||
<div class="tour-section row">
|
||||
<div class="col-md-12">
|
||||
<div class="tour-section-title">Organizations</div>
|
||||
<div class="tour-section-description">
|
||||
Organizations in Quay provide unique features for businesses and other
|
||||
groups, including team-based sharing and fine-grained permission controls.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="tour-section row">
|
||||
<div class="col-md-7"><img src="/static/img/org-repo-list.png" title="Repositories - Quay" data-screenshot-url="https://quay.io/repository/" class="img-responsive"></div>
|
||||
<div class="col-md-5">
|
||||
<div class="tour-section-title">A central collection of repositories</div>
|
||||
<div class="tour-section-description">
|
||||
Your organization is the focal point for all activity that occurs within
|
||||
your public or private repositories. Your repositories are centrally visible
|
||||
and managed within the namespace of your organization. You may share
|
||||
your repositories with as many users and teams as you like, without
|
||||
any additional cost.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tour-section row">
|
||||
<div class="col-md-7 col-md-push-5"><img src="/static/img/org-admin.png" title="buynlarge Admin - Quay" data-screenshot-url="https://quay.io/organization/buynlarge/admin" class="img-responsive"></div>
|
||||
<div class="col-md-5 col-md-pull-7">
|
||||
<div class="tour-section-title">Organization settings at a glance</div>
|
||||
<div class="tour-section-description">
|
||||
Your organization allows you to view your private repository count
|
||||
and manage billing settings in a centralized place.
|
||||
</div>
|
||||
<div class="tour-section-description">
|
||||
You can also see all of the users who have access to your organization
|
||||
and the teams of which they are members. This allows you to audit the
|
||||
access that has been granted in your organization.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tour-section row">
|
||||
<div class="col-md-7"><img src="/static/img/org-teams.png" title="buynlarge - Quay" data-screenshot-url="https://quay.io/organization/buynlarge" class="img-responsive"></div>
|
||||
<div class="col-md-5">
|
||||
<div class="tour-section-title">Teams simplify access controls</div>
|
||||
<div class="tour-section-description">
|
||||
Teams allow your organization to delegate access to your namespace and
|
||||
repositories in a controlled fashion. Each team has permissions that
|
||||
apply across the entire org, and can also be given specific levels of
|
||||
access to specific repositories. A user is switching roles? No problem,
|
||||
change their team membership and their access will be adjusted accordingly.
|
||||
</div>
|
||||
<div class="tour-section-description">
|
||||
Owners of your organization, and members of other teams with
|
||||
administrator privileges, have full permissions to all repositories
|
||||
in the organization, as well as permissions to view and adjust the
|
||||
account settings for the organization. Add users to these teams with
|
||||
caution.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tour-section row">
|
||||
<div class="col-md-7 col-md-push-5"><img src="/static/img/org-repo-admin.png" title="buynlarge/orgrepo - Quay" data-screenshot-url="https://quay.io/repository/buynlarge/orgrepo" class="img-responsive"></div>
|
||||
<div class="col-md-5 col-md-pull-7">
|
||||
<div class="tour-section-title">Fine-grained control of sharing</div>
|
||||
<div class="tour-section-description">
|
||||
Repositories that you create within your organization can be assigned
|
||||
fine-grained permissions just like any other repository. You can also
|
||||
add teams that exist in your organization, or individual users from
|
||||
inside our outside your organization.
|
||||
</div>
|
||||
<div class="tour-section-description">
|
||||
In order to protect your intellectual property, we warn you before
|
||||
you share your repositories with anyone who is not currently a member
|
||||
of a team in your organization.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-bar-right button-bar-bottom">
|
||||
<a href="/organizations/new/" title="Starts the process to create a new organization" bs-tooltip="tooltip.title">
|
||||
<button class="btn btn-success">
|
||||
<i class="fa fa-plus"></i>
|
||||
Create New Organization
|
||||
</button>
|
||||
</a>
|
||||
<a href="/user/?migrate" ng-show="!user.anonymous" title="Starts the process to convert this account into an organization" bs-tooltip="tooltip.title">
|
||||
<button class="btn btn-primary">
|
||||
<i class="fa fa-caret-square-o-right"></i>
|
||||
Convert account
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -7,15 +7,39 @@
|
|||
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="plans-list">
|
||||
<div class="plan" ng-repeat="plan in plans" 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>
|
||||
<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.user">
|
||||
<div class="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-primary btn-block" ng-click="buyNow(plan.stripeId)">Sign Up Now</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-block" ng-click="buyNow(plan.stripeId)">Sign Up Now</button>
|
||||
<div class="callout">
|
||||
Business Plan Pricing
|
||||
</div>
|
||||
|
||||
<div class="all-plans">
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
|
|
|
@ -20,44 +20,61 @@
|
|||
|
||||
<!-- User Access Permissions -->
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">User Access Permissions
|
||||
<div class="panel-heading">User <span ng-show="repo.is_organization">and Team</span> Access Permissions
|
||||
|
||||
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="Allow any number of users to read, write or administer this repository"></i>
|
||||
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="Allow any number of users or teams to read, write or administer this repository"></i>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
|
||||
<table class="permissions">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>User</td>
|
||||
<td>User<span ng-show="repo.is_organization">/Team</span></td>
|
||||
<td>Permissions</td>
|
||||
<td></td>
|
||||
<td style="width: 95px;"></td>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tr ng-repeat="(username, permission) in permissions">
|
||||
<td class="user">
|
||||
<!-- Team Permissions -->
|
||||
<tr ng-repeat="(name, permission) in permissions['team']">
|
||||
<td class="team entity">
|
||||
<i class="fa fa-group"></i>
|
||||
<span><a href="/organization/{{ repo.namespace }}/teams/{{ name }}">{{name}}</a></span>
|
||||
</td>
|
||||
<td class="user-permissions">
|
||||
<span class="role-group" current-role="permission.role" role-changed="setRole(role, name, 'team')" roles="roles"></span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="delete-ui" tabindex="0">
|
||||
<span class="delete-ui-button" ng-click="deleteRole(name, 'team')"><button class="btn btn-danger">Delete</button></span>
|
||||
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="right" title="Delete Permission"></i>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- User Permissions -->
|
||||
<tr ng-repeat="(name, permission) in permissions['user']">
|
||||
<td class="{{ 'user entity ' + (permission.is_org_member? '' : 'outside') }}">
|
||||
<i class="fa fa-user"></i>
|
||||
<span>{{username}}</span>
|
||||
<span>{{name}}</span>
|
||||
<i class="fa fa-exclamation-triangle" ng-show="permission.is_org_member === false" data-trigger="hover" bs-popover="{'content': 'This user is not a member of the organization'}"></i>
|
||||
</td>
|
||||
<td class="user-permissions">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-default" ng-click="setRole(username, 'read')" ng-class="{read: 'active', write: '', admin: ''}[permission.role]">Read only</button>
|
||||
<button type="button" class="btn btn-default" ng-click="setRole(username, 'write')" ng-class="{read: '', write: 'active', admin: ''}[permission.role]">Write</button>
|
||||
<button type="button" class="btn btn-default" ng-click="setRole(username, 'admin')" ng-class="{read: '', write: '', admin: 'active'}[permission.role]">Admin</button>
|
||||
<span class="role-group" current-role="permission.role" role-changed="setRole(role, name, 'user')" roles="roles"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="delete-ui" tabindex="0" title="Delete Permission">
|
||||
<span class="delete-ui-button" ng-click="deleteRole(username)"><button class="btn btn-danger">Delete</button></span>
|
||||
<i class="fa fa-times"></i>
|
||||
<span class="delete-ui-button" ng-click="deleteRole(name, 'user')"><button class="btn btn-danger">Delete</button></span>
|
||||
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="right" title="Delete Permission"></i>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<input id="userSearch" class="form-control" placeholder="Add new user...">
|
||||
<span class="entity-search" organization="repo.namespace" input-title="'Add a ' + (repo.is_organization ? 'team or ' : '') + 'user...'" entity-selected="addNewPermission"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
@ -93,9 +110,9 @@
|
|||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="delete-ui" tabindex="0" title="Delete Token">
|
||||
<span class="delete-ui" tabindex="0">
|
||||
<span class="delete-ui-button" ng-click="deleteToken(token.code)"><button class="btn btn-danger" type="button">Delete</button></span>
|
||||
<i class="fa fa-times"></i>
|
||||
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="right" title="Delete Token"></i>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -246,7 +263,7 @@
|
|||
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="onlyadminModal">
|
||||
<div class="modal fade" id="channgechangepermModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
|
@ -254,7 +271,8 @@
|
|||
<h4 class="modal-title">Cannot change permissions</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
The selected permissions could not be changed because the user is the only <b>admin</b> on the repo.
|
||||
<span ng-show="!changePermError">You do not have permission to change the permissions on the repository.</span>
|
||||
<span ng-show="changePermError">{{ changePermError }}</span>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
|
@ -283,4 +301,24 @@
|
|||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="confirmaddoutsideModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Add User?</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
The selected user is outside of your organization. Are you sure you want to grant the user access to this repository?
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" ng-click="grantRole()">Yes, I'm sure</button>
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
|
||||
</div>
|
||||
|
|
|
@ -4,25 +4,41 @@
|
|||
|
||||
<div class="container ready-indicator" ng-show="!loading" data-status="{{ loading ? '' : 'ready' }}">
|
||||
<div class="repo-list" ng-show="!user.anonymous">
|
||||
<a href="/new/">
|
||||
<button class="btn btn-success" style="float: right">
|
||||
<i class="fa fa-upload user-tool" title="Create new repository"></i>
|
||||
Create Repository
|
||||
</button>
|
||||
</a>
|
||||
<div ng-class="user.organizations.length ? 'section-header' : ''">
|
||||
<div class="button-bar-right">
|
||||
<a href="/new/">
|
||||
<button class="btn btn-success">
|
||||
<i class="fa fa-upload user-tool" title="Create new repository"></i>
|
||||
Create Repository
|
||||
</button>
|
||||
</a>
|
||||
|
||||
<a href="/organization/{{ namespace }}" ng-show="namespace != user.username">
|
||||
<button class="btn btn-default">
|
||||
<i class="fa fa-group user-tool"></i>
|
||||
View Organization
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<span class="namespace-selector" user="user" namespace="namespace" ng-show="user.organizations"></span>
|
||||
</div>
|
||||
|
||||
<h3>Your Repositories</h3>
|
||||
<div ng-show="private_repositories.length > 0">
|
||||
<div class="repo-listing" ng-repeat="repository in private_repositories">
|
||||
<h3 ng-show="namespace == user.username">Your Repositories</h3>
|
||||
<h3 ng-show="namespace != user.username">Repositories</h3>
|
||||
|
||||
<div ng-show="user_repositories.length > 0">
|
||||
<div class="repo-listing" ng-repeat="repository in user_repositories">
|
||||
<span class="repo-circle no-background" repo="repository"></span>
|
||||
<a ng-href="/repository/{{repository.namespace}}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
|
||||
<div class="description" ng-bind-html-unsafe="getCommentFirstLine(repository.description)"></div>
|
||||
<div class="description markdown-view" content="repository.description" first-line-only="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-show="private_repositories.length == 0" style="padding:20px;">
|
||||
<div ng-show="user_repositories.length == 0" style="padding:20px;">
|
||||
<div class="alert alert-info">
|
||||
<h4>You don't have any repositories yet!</h4>
|
||||
<h4 ng-show="namespace == user.username">You don't have any repositories yet!</h4>
|
||||
<h4 ng-show="namespace != user.username">This organization doesn't have any repositories, or you have not been provided access.</h4>
|
||||
<a href="/guide"><b>Click here</b> to learn how to create a repository</a>
|
||||
</div>
|
||||
|
||||
|
@ -34,7 +50,7 @@
|
|||
<div class="repo-listing" ng-repeat="repository in public_repositories">
|
||||
<span class="repo-circle no-background" repo="repository"></span>
|
||||
<a ng-href="/repository/{{repository.namespace}}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
|
||||
<div class="description" ng-bind-html-unsafe="getCommentFirstLine(repository.description)"></div>
|
||||
<div class="description markdown-view" content="repository.description" first-line-only="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -12,22 +12,7 @@
|
|||
</div>
|
||||
<div id="collapseSignin" class="panel-collapse collapse in">
|
||||
<div class="panel-body">
|
||||
<form class="form-signin" ng-submit="signin();">
|
||||
<input type="text" class="form-control input-lg" name="username" placeholder="Username" ng-model="user.username" autofocus>
|
||||
<input type="password" class="form-control input-lg" name="password" placeholder="Password" ng-model="user.password">
|
||||
<button class="btn btn-lg btn-primary btn-block" type="submit">Sign In</button>
|
||||
|
||||
<span class="social-alternate">
|
||||
<i class="fa fa-circle"></i>
|
||||
<span class="inner-text">OR</span>
|
||||
</span>
|
||||
|
||||
<a id='github-signin-link' href="https://github.com/login/oauth/authorize?client_id={{ githubClientId }}&scope=user:email{{ mixpanelDistinctIdClause }}" class="btn btn-primary btn-lg btn-block"><i class="fa fa-github fa-lg"></i> Sign In with GitHub</a>
|
||||
</form>
|
||||
|
||||
<div class="alert alert-danger" ng-show="invalidCredentials">Invalid username or password.</div>
|
||||
|
||||
<div class="alert alert-danger" ng-show="needsEmailVerification">You must verify your email address before you can sign in.</div>
|
||||
<div class="signin-form"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -56,17 +41,3 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <script type="text/javascript">
|
||||
function appendMixpanelId() {
|
||||
if (mixpanel.get_distinct_id !== undefined) {
|
||||
var signinLink = document.getElementById("github-signin-link");
|
||||
signinLink.href += ("&state=" + mixpanel.get_distinct_id());
|
||||
} else {
|
||||
// Mixpanel not yet loaded, try again later
|
||||
window.setTimeout(appendMixpanelId, 200);
|
||||
}
|
||||
};
|
||||
|
||||
appendMixpanelId();
|
||||
</script> -->
|
||||
|
|
79
static/partials/team-view.html
Normal file
|
@ -0,0 +1,79 @@
|
|||
<div class="loading" ng-show="loading">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
|
||||
<div class="loading" ng-show="!loading && !organization">
|
||||
No matching team found
|
||||
</div>
|
||||
|
||||
<div class="team-view container" ng-show="!loading && organization">
|
||||
<div class="organization-header" organization="organization" team-name="teamname"></div>
|
||||
|
||||
<div class="description markdown-input" content="team.description" can-write="organization.is_admin"
|
||||
content-changed="updateForDescription" field-title="'team description'"></div>
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">Team Members
|
||||
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="Users that inherit all permissions delegated to this team"></i>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table class="permissions">
|
||||
<tr ng-repeat="(name, member) in members">
|
||||
<td class="user entity">
|
||||
<i class="fa fa-user"></i>
|
||||
<span>{{ member.username }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="delete-ui" tabindex="0" title="Remove User" ng-show="canEditMembers">
|
||||
<span class="delete-ui-button" ng-click="removeMember(member.username)"><button class="btn btn-danger">Remove</button></span>
|
||||
<i class="fa fa-times"></i>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr ng-show="canEditMembers">
|
||||
<td colspan="2">
|
||||
<span class="entity-search" organization="''" input-title="'Add a user...'" entity-selected="addNewMember"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="cannotChangeTeamModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Cannot change team</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
You do not have permission to change properties of this team.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="cannotChangeMembersModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Cannot change members</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
You do not have permission to change the members of this team.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
|
@ -1,80 +1,181 @@
|
|||
<div class="container user-admin">
|
||||
<div class="loading" ng-show="planLoading || planChanging">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
<div class="row" ng-show="errorMessage">
|
||||
<div class="col-md-12">
|
||||
<div class="alert alert-danger">{{ errorMessage }}</div>
|
||||
<div class="loading" ng-show="loading">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
|
||||
<div class="loading" ng-show="!loading && !user">
|
||||
No matching user found
|
||||
</div>
|
||||
|
||||
<div class="user-admin container" ng-show="!loading && user">
|
||||
<div class="row">
|
||||
<div class="organization-header-element">
|
||||
<img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=24&d=identicon">
|
||||
<span class="organization-name">
|
||||
{{ user.username }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="askForPassword">
|
||||
<div class="col-md-12">
|
||||
<div class="alert alert-warning">Your account does not currently have a password. You will need to create a password before you will be able to <strong>push</strong> or <strong>pull</strong> repositories.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" ng-hide="planLoading">
|
||||
<div class="col-md-3" ng-repeat='plan in plans'>
|
||||
<div class="panel" ng-class="{'panel-success': subscription.plan == plan.stripeId, 'panel-default': subscription.plan != plan.stripeId}">
|
||||
<div class="panel-heading">
|
||||
{{ plan.title }}
|
||||
<span class="pull-right" ng-show="subscription.plan == plan.stripeId">
|
||||
<i class="fa fa-ok"></i>
|
||||
Subscribed
|
||||
</span>
|
||||
</div>
|
||||
<div class="panel-body panel-plan">
|
||||
<div class="plan-price">${{ plan.price / 100 }}</div>
|
||||
<div class="plan-description"><b>{{ plan.privateRepos }}</b> Private Repositories</div>
|
||||
<div ng-switch='plan.stripeId'>
|
||||
<div ng-switch-when='free'>
|
||||
<button class="btn button-hidden">Hidden!</button>
|
||||
</div>
|
||||
<div ng-switch-default>
|
||||
<button class="btn btn-primary" ng-show="subscription.plan === 'free'" ng-click="subscribe(plan.stripeId)">Subscribe</button>
|
||||
<button class="btn btn-default" ng-hide="subscription.plan === 'free' || subscription.plan === plan.stripeId" ng-click="changeSubscription(plan.stripeId)">Change</button>
|
||||
<button class="btn btn-danger" ng-show="subscription.plan === plan.stripeId" ng-click="cancelSubscription()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" ng-show="subscription">
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
Plan Usage
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="used-description">
|
||||
<b>{{ subscription.usedPrivateRepos }}</b> of <b>{{ subscribedPlan.privateRepos }}</b> private repositories used
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div ng-class="'progress-bar ' + (planUsagePercent > 90 ? 'progress-bar-danger' : '')" role="progressbar" aria-valuenow="{{ subscription.usedPrivateRepos }}" aria-valuemin="0" aria-valuemax="{{ subscribedPlan.privateRepos }}" style="width: {{ planUsagePercent }}%;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="loading" ng-show="updatingUser">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
<!-- Side tabs -->
|
||||
<div class="col-md-2">
|
||||
<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><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="#migrate" id="migrateTab">Convert to Organization</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
Change Password
|
||||
|
||||
<!-- Content -->
|
||||
<div class="col-md-10">
|
||||
<div class="tab-content">
|
||||
<!-- Plans tab -->
|
||||
<div id="plan" class="tab-pane active">
|
||||
<div class="plan-manager" user="user.username" ready-for-plan="readyForPlan()"></div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form class="form-change-pw" name="changePasswordForm" ng-submit="changePassword()" data-trigger="manual" data-content="{{ changePasswordError }}" data-placement="right" ng-show="!awaitingConfirmation && !registering">
|
||||
<input type="password" class="form-control" placeholder="Your new password" ng-model="user.password" required>
|
||||
<input type="password" class="form-control" placeholder="Verify your new password" ng-model="user.repeatPassword" match="user.password" required>
|
||||
<button class="btn btn-danger" ng-disabled="changePasswordForm.$invalid" type="submit" analytics-on analytics-event="register">Change Password</button>
|
||||
<span class="help-block" ng-show="changePasswordSuccess">Password changed successfully</span>
|
||||
</form>
|
||||
|
||||
<!-- Change password tab -->
|
||||
<div id="password" class="tab-pane">
|
||||
<div class="loading" ng-show="updatingUser">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
<div class="row">
|
||||
<form class="form-change-pw col-md-6" name="changePasswordForm" ng-submit="changePassword()" data-trigger="manual"
|
||||
data-content="{{ changePasswordError }}" data-placement="right" ng-show="!awaitingConfirmation && !registering">
|
||||
<input type="password" class="form-control" placeholder="Your new password" ng-model="user.password" required>
|
||||
<input type="password" class="form-control" placeholder="Verify your new password" ng-model="user.repeatPassword"
|
||||
match="user.password" required>
|
||||
<button class="btn btn-danger" ng-disabled="changePasswordForm.$invalid" type="submit"
|
||||
analytics-on analytics-event="register">Change Password</button>
|
||||
<span class="help-block" ng-show="changePasswordSuccess">Password changed successfully</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Convert to organization tab -->
|
||||
<div id="migrate" class="tab-pane">
|
||||
<!-- Step 0 -->
|
||||
<div class="panel" ng-show="convertStep == 0">
|
||||
<div class="panel-body" ng-show="user.organizations.length > 0">
|
||||
<div class="alert alert-info">
|
||||
Cannot convert this account into an organization, as it is a member of {{user.organizations.length}} other
|
||||
organization{{user.organizations.length > 1 ? 's' : ''}}. Please leave
|
||||
{{user.organizations.length > 1 ? 'those organizations' : 'that organization'}} first.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-body" ng-show="user.organizations.length == 0">
|
||||
<div class="alert alert-danger">
|
||||
Converting a user account into an organization <b>cannot be undone</b>.<br> Here be many fire-breathing dragons!
|
||||
</div>
|
||||
|
||||
<button class="btn btn-danger" ng-click="showConvertForm()">Start conversion process</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1 -->
|
||||
<div class="convert-form" ng-show="convertStep == 1">
|
||||
<h3>Convert to organization</h3>
|
||||
|
||||
<form method="post" name="convertForm" id="convertForm" ng-submit="convertToOrg()">
|
||||
<div class="form-group">
|
||||
<label for="orgName">Organization Name</label>
|
||||
<div class="existing-data">
|
||||
<img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=24&d=identicon">
|
||||
{{ user.username }}</div>
|
||||
<span class="description">This will continue to be the namespace for your repositories</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="orgName">Admin User</label>
|
||||
<input id="adminUsername" name="adminUsername" type="text" class="form-control" placeholder="Admin Username"
|
||||
ng-model="org.adminUser" required autofocus>
|
||||
<input id="adminPassword" name="adminPassword" type="password" class="form-control" placeholder="Admin Password"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- Plans Table -->
|
||||
<div class="form-group plan-group">
|
||||
<label>Organization Plan</label>
|
||||
<div class="plans-table" plans="orgPlans" current-plan="org.plan"></div>
|
||||
</div>
|
||||
|
||||
<div class="button-bar">
|
||||
<button class="btn btn-large btn-danger" type="submit" ng-disabled="convertForm.$invalid || !org.plan">
|
||||
Convert To Organization
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="cannotconvertModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Cannot convert account</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Your account could not be converted. Please try again in a moment.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="invalidadminModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Username or password invalid</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
The username or password specified for the admin account is not valid.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="reallyconvertModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Convert to organization?</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-danger">You will not be able to login to this account once converted</div>
|
||||
<div>Are you <b>absolutely sure</b> you would like to convert this account to an organization? Once done, there is no going back.</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger" data-dismiss="modal" ng-click="reallyConvert()">Absolutely: Convert Now</button>
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
<span style="color: #aaa;"> {{repo.namespace}}</span> <span style="color: #ccc">/</span> {{repo.name}}
|
||||
|
||||
<span class="settings-cog" ng-show="repo.can_admin" title="Repository Settings">
|
||||
<span class="settings-cog" ng-show="repo.can_admin" title="Repository Settings" bs-tooltip="tooltip.title" data-placement="bottom">
|
||||
<a href="{{ '/repository/' + repo.namespace + '/' + repo.name + '/admin' }}">
|
||||
<i class="fa fa-cog fa-lg"></i>
|
||||
</a>
|
||||
|
@ -52,12 +52,8 @@
|
|||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="description">
|
||||
<p ng-class="'lead ' + (repo.can_write ? 'editable' : 'noteditable')" ng-click="editDescription()">
|
||||
<span class="content" ng-bind-html-unsafe="getMarkedDown(repo.description)"></span>
|
||||
<i class="fa fa-edit"></i>
|
||||
</p>
|
||||
</div>
|
||||
<div class="description markdown-input" content="repo.description" can-write="repo.can_write"
|
||||
content-changed="updateForDescription" field-title="'repository description'"></div>
|
||||
|
||||
<!-- Empty message -->
|
||||
<div class="repo-content" ng-show="!currentTag.image && !repo.is_building">
|
||||
|
@ -79,7 +75,7 @@
|
|||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<!-- Tag dropdown -->
|
||||
<div class="tag-dropdown dropdown" title="Tags">
|
||||
<div class="tag-dropdown dropdown" title="Tags" bs-tooltip="tooltip.title" data-placement="top">
|
||||
<i class="fa fa-tag"><span class="tag-count">{{getTagCount(repo)}}</span></i>
|
||||
<a href="javascript:void(0)" class="dropdown-toggle" data-toggle="dropdown">{{currentTag.name}} <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu">
|
||||
|
@ -107,7 +103,7 @@
|
|||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<!-- Image dropdown -->
|
||||
<div class="tag-dropdown dropdown" title="Images">
|
||||
<div class="tag-dropdown dropdown" title="Images" bs-tooltip="tooltip.title" data-placement="top">
|
||||
<i class="fa fa-archive"><span class="tag-count">{{imageHistory.length}}</span></i>
|
||||
<a href="javascript:void(0)" class="dropdown-toggle" data-toggle="dropdown">{{currentImage.id.substr(0, 12)}} <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu">
|
||||
|
@ -122,7 +118,8 @@
|
|||
<div class="panel-body">
|
||||
<div id="current-image">
|
||||
<div ng-show="currentImage.comment">
|
||||
<blockquote style="margin-top: 10px;" ng-bind-html-unsafe="getMarkedDown(currentImage.comment)">
|
||||
<blockquote style="margin-top: 10px;">
|
||||
<span class="markdown-view" content="currentImage.comment"></span>
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
|
@ -141,15 +138,18 @@
|
|||
<div class="changes-container small-changes-container"
|
||||
ng-show="currentImageChanges.changed.length || currentImageChanges.added.length || currentImageChanges.removed.length">
|
||||
<div class="changes-count-container accordion-toggle" data-toggle="collapse" data-parent="#accordion" data-target="#collapseChanges">
|
||||
<span class="change-count added" ng-show="currentImageChanges.added.length > 0" title="Files Added">
|
||||
<span class="change-count added" ng-show="currentImageChanges.added.length > 0" title="Files Added"
|
||||
bs-tooltip="tooltip.title" data-placement="top">
|
||||
<i class="fa fa-plus-square"></i>
|
||||
<b>{{currentImageChanges.added.length}}</b>
|
||||
</span>
|
||||
<span class="change-count removed" ng-show="currentImageChanges.removed.length > 0" title="Files Removed">
|
||||
<span class="change-count removed" ng-show="currentImageChanges.removed.length > 0" title="Files Removed"
|
||||
bs-tooltip="tooltip.title" data-placement="top">
|
||||
<i class="fa fa-minus-square"></i>
|
||||
<b>{{currentImageChanges.removed.length}}</b>
|
||||
</span>
|
||||
<span class="change-count changed" ng-show="currentImageChanges.changed.length > 0" title="Files Changed">
|
||||
<span class="change-count changed" ng-show="currentImageChanges.changed.length > 0" title="Files Changed"
|
||||
bs-tooltip="tooltip.title" data-placement="top">
|
||||
<i class="fa fa-pencil-square"></i>
|
||||
<b>{{currentImageChanges.changed.length}}</b>
|
||||
</span>
|
||||
|
@ -182,29 +182,4 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal edit for the description -->
|
||||
<div class="modal fade" id="editModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Edit Repository Description</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="wmd-panel">
|
||||
<div id="wmd-button-bar-description"></div>
|
||||
<textarea class="wmd-input" id="wmd-input-description" placeholder="Enter description">{{ repo.description }}</textarea>
|
||||
</div>
|
||||
|
||||
<div id="wmd-preview-description" class="wmd-panel wmd-preview"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" ng-click="saveDescription()">Save changes</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
|
||||
</div>
|
||||
|
|
|
@ -1,150 +0,0 @@
|
|||
import logging
|
||||
|
||||
import contextlib
|
||||
import tempfile
|
||||
|
||||
from app import app
|
||||
|
||||
|
||||
__all__ = ['load']
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Storage(object):
|
||||
|
||||
"""Storage is organized as follow:
|
||||
$ROOT/images/<image_id>/json
|
||||
$ROOT/images/<image_id>/layer
|
||||
$ROOT/repositories/<namespace>/<repository_name>/<tag_name>
|
||||
"""
|
||||
|
||||
# Useful if we want to change those locations later without rewriting
|
||||
# the code which uses Storage
|
||||
repositories = 'repositories'
|
||||
images = 'images'
|
||||
# Set the IO buffer to 64kB
|
||||
buffer_size = 64 * 1024
|
||||
|
||||
#FIXME(samalba): Move all path resolver in each module (out of the base)
|
||||
def images_list_path(self, namespace, repository):
|
||||
return '{0}/{1}/{2}/_images_list'.format(self.repositories,
|
||||
namespace,
|
||||
repository)
|
||||
|
||||
def image_json_path(self, namespace, repository, image_id):
|
||||
return '{0}/{1}/{2}/{3}/json'.format(self.images, namespace,
|
||||
repository, image_id)
|
||||
|
||||
def image_mark_path(self, namespace, repository, image_id):
|
||||
return '{0}/{1}/{2}/{3}/_inprogress'.format(self.images, namespace,
|
||||
repository, image_id)
|
||||
|
||||
def image_checksum_path(self, namespace, repository, image_id):
|
||||
return '{0}/{1}/{2}/{3}/_checksum'.format(self.images, namespace,
|
||||
repository, image_id)
|
||||
|
||||
def image_layer_path(self, namespace, repository, image_id):
|
||||
return '{0}/{1}/{2}/{3}/layer'.format(self.images, namespace,
|
||||
repository, image_id)
|
||||
|
||||
def image_ancestry_path(self, namespace, repository, image_id):
|
||||
return '{0}/{1}/{2}/{3}/ancestry'.format(self.images, namespace,
|
||||
repository, image_id)
|
||||
|
||||
def repository_namespace_path(self, namespace, repository):
|
||||
return '{0}/{1}/{2}/'.format(self.images, namespace, repository)
|
||||
|
||||
def image_file_trie_path(self, namespace, repository, image_id):
|
||||
return '{0}/{1}/{2}/{3}/files.trie'.format(self.images, namespace,
|
||||
repository, image_id)
|
||||
|
||||
def image_file_diffs_path(self, namespace, repository, image_id):
|
||||
return '{0}/{1}/{2}/{3}/diffs.json'.format(self.images, namespace,
|
||||
repository, image_id)
|
||||
|
||||
def get_content(self, path):
|
||||
raise NotImplementedError
|
||||
|
||||
def put_content(self, path, content):
|
||||
raise NotImplementedError
|
||||
|
||||
def stream_read(self, path):
|
||||
raise NotImplementedError
|
||||
|
||||
def stream_read_file(self, path):
|
||||
raise NotImplementedError
|
||||
|
||||
def stream_write(self, path, fp):
|
||||
raise NotImplementedError
|
||||
|
||||
def list_directory(self, path=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def exists(self, path):
|
||||
raise NotImplementedError
|
||||
|
||||
def remove(self, path):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_size(self, path):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def store_stream(stream):
|
||||
"""Stores the entire stream to a temporary file."""
|
||||
tmpf = tempfile.TemporaryFile()
|
||||
while True:
|
||||
try:
|
||||
buf = stream.read(4096)
|
||||
if not buf:
|
||||
break
|
||||
tmpf.write(buf)
|
||||
except IOError:
|
||||
break
|
||||
tmpf.seek(0)
|
||||
yield tmpf
|
||||
tmpf.close()
|
||||
|
||||
|
||||
def temp_store_handler():
|
||||
tmpf = tempfile.TemporaryFile()
|
||||
|
||||
def fn(buf):
|
||||
try:
|
||||
tmpf.write(buf)
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
return tmpf, fn
|
||||
|
||||
|
||||
from local import LocalStorage
|
||||
from s3 import S3Storage
|
||||
|
||||
|
||||
_storage = {}
|
||||
|
||||
|
||||
def load(kind=None):
|
||||
"""Returns the right storage class according to the configuration."""
|
||||
global _storage
|
||||
|
||||
# TODO hard code to local for now
|
||||
kind = app.config['STORAGE_KIND']
|
||||
# if not kind:
|
||||
# kind = cfg.storage.lower()
|
||||
if kind in _storage:
|
||||
return _storage[kind]
|
||||
if kind == 's3':
|
||||
logger.debug('Using s3 storage.')
|
||||
store = S3Storage('', app.config['AWS_ACCESS_KEY'],
|
||||
app.config['AWS_SECRET_KEY'],
|
||||
app.config['REGISTRY_S3_BUCKET'])
|
||||
elif kind == 'local':
|
||||
logger.debug('Using local storage.')
|
||||
store = LocalStorage(app.config['LOCAL_STORAGE_DIR'])
|
||||
else:
|
||||
raise ValueError('Not supported storage \'{0}\''.format(kind))
|
||||
_storage[kind] = store
|
||||
return store
|
78
storage/basestorage.py
Normal file
|
@ -0,0 +1,78 @@
|
|||
class Storage(object):
|
||||
|
||||
"""Storage is organized as follow:
|
||||
$ROOT/images/<image_id>/json
|
||||
$ROOT/images/<image_id>/layer
|
||||
$ROOT/repositories/<namespace>/<repository_name>/<tag_name>
|
||||
"""
|
||||
|
||||
# Useful if we want to change those locations later without rewriting
|
||||
# the code which uses Storage
|
||||
repositories = 'repositories'
|
||||
images = 'images'
|
||||
# Set the IO buffer to 64kB
|
||||
buffer_size = 64 * 1024
|
||||
|
||||
#FIXME(samalba): Move all path resolver in each module (out of the base)
|
||||
def images_list_path(self, namespace, repository):
|
||||
return '{0}/{1}/{2}/_images_list'.format(self.repositories,
|
||||
namespace,
|
||||
repository)
|
||||
|
||||
def image_json_path(self, namespace, repository, image_id):
|
||||
return '{0}/{1}/{2}/{3}/json'.format(self.images, namespace,
|
||||
repository, image_id)
|
||||
|
||||
def image_mark_path(self, namespace, repository, image_id):
|
||||
return '{0}/{1}/{2}/{3}/_inprogress'.format(self.images, namespace,
|
||||
repository, image_id)
|
||||
|
||||
def image_checksum_path(self, namespace, repository, image_id):
|
||||
return '{0}/{1}/{2}/{3}/_checksum'.format(self.images, namespace,
|
||||
repository, image_id)
|
||||
|
||||
def image_layer_path(self, namespace, repository, image_id):
|
||||
return '{0}/{1}/{2}/{3}/layer'.format(self.images, namespace,
|
||||
repository, image_id)
|
||||
|
||||
def image_ancestry_path(self, namespace, repository, image_id):
|
||||
return '{0}/{1}/{2}/{3}/ancestry'.format(self.images, namespace,
|
||||
repository, image_id)
|
||||
|
||||
def repository_namespace_path(self, namespace, repository):
|
||||
return '{0}/{1}/{2}/'.format(self.images, namespace, repository)
|
||||
|
||||
def image_file_trie_path(self, namespace, repository, image_id):
|
||||
return '{0}/{1}/{2}/{3}/files.trie'.format(self.images, namespace,
|
||||
repository, image_id)
|
||||
|
||||
def image_file_diffs_path(self, namespace, repository, image_id):
|
||||
return '{0}/{1}/{2}/{3}/diffs.json'.format(self.images, namespace,
|
||||
repository, image_id)
|
||||
|
||||
def get_content(self, path):
|
||||
raise NotImplementedError
|
||||
|
||||
def put_content(self, path, content):
|
||||
raise NotImplementedError
|
||||
|
||||
def stream_read(self, path):
|
||||
raise NotImplementedError
|
||||
|
||||
def stream_read_file(self, path):
|
||||
raise NotImplementedError
|
||||
|
||||
def stream_write(self, path, fp):
|
||||
raise NotImplementedError
|
||||
|
||||
def list_directory(self, path=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def exists(self, path):
|
||||
raise NotImplementedError
|
||||
|
||||
def remove(self, path):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_size(self, path):
|
||||
raise NotImplementedError
|
|
@ -2,7 +2,7 @@
|
|||
import os
|
||||
import shutil
|
||||
|
||||
from . import Storage
|
||||
from basestorage import Storage
|
||||
|
||||
|
||||
class LocalStorage(Storage):
|
||||
|
|
|
@ -5,7 +5,7 @@ import logging
|
|||
import boto.s3.connection
|
||||
import boto.s3.key
|
||||
|
||||
from . import Storage
|
||||
from basestorage import Storage
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
|
@ -14,7 +14,6 @@
|
|||
|
||||
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/font-awesome/4.0.0/css/font-awesome.css">
|
||||
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.no-icons.min.css">
|
||||
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap-theme.min.css">
|
||||
<link href='//fonts.googleapis.com/css?family=Droid+Sans:400,700' rel='stylesheet' type='text/css'>
|
||||
|
||||
<link rel="stylesheet" href="/static/css/quay.css">
|
||||
|
@ -48,6 +47,7 @@
|
|||
<script src="static/lib/angulartics-mixpanel.js"></script>
|
||||
|
||||
<script src="static/lib/angular-moment.min.js"></script>
|
||||
<script src="static/lib/angular-cookies.min.js"></script>
|
||||
|
||||
<script src="static/lib/typeahead.min.js"></script>
|
||||
|
||||
|
|
0
test/__init__.py
Normal file
6
test/analytics.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
class FakeMixpanel(object):
|
||||
def track(*args, **kwargs):
|
||||
pass
|
||||
|
||||
def init_app(app):
|
||||
return FakeMixpanel()
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"removed": [],
|
||||
"added": [
|
||||
"/opt/elasticsearch-0.90.5/LICENSE.txt",
|
||||
"/opt/elasticsearch-0.90.5/NOTICE.txt",
|
||||
"/opt/elasticsearch-0.90.5/README.textile",
|
||||
"/opt/elasticsearch-0.90.5/bin/elasticsearch",
|
||||
"/opt/elasticsearch-0.90.5/bin/elasticsearch.in.sh",
|
||||
"/opt/elasticsearch-0.90.5/bin/plugin",
|
||||
"/opt/elasticsearch-0.90.5/config/elasticsearch.yml",
|
||||
"/opt/elasticsearch-0.90.5/config/logging.yml",
|
||||
"/opt/elasticsearch-0.90.5/lib/elasticsearch-0.90.5.jar",
|
||||
"/opt/elasticsearch-0.90.5/lib/jna-3.3.0.jar",
|
||||
"/opt/elasticsearch-0.90.5/lib/jts-1.12.jar",
|
||||
"/opt/elasticsearch-0.90.5/lib/log4j-1.2.17.jar",
|
||||
"/opt/elasticsearch-0.90.5/lib/lucene-analyzers-common-4.4.0.jar",
|
||||
"/opt/elasticsearch-0.90.5/lib/lucene-codecs-4.4.0.jar",
|
||||
"/opt/elasticsearch-0.90.5/lib/lucene-core-4.4.0.jar",
|
||||
"/opt/elasticsearch-0.90.5/lib/lucene-grouping-4.4.0.jar",
|
||||
"/opt/elasticsearch-0.90.5/lib/lucene-highlighter-4.4.0.jar",
|
||||
"/opt/elasticsearch-0.90.5/lib/lucene-join-4.4.0.jar",
|
||||
"/opt/elasticsearch-0.90.5/lib/lucene-memory-4.4.0.jar",
|
||||
"/opt/elasticsearch-0.90.5/lib/lucene-misc-4.4.0.jar",
|
||||
"/opt/elasticsearch-0.90.5/lib/lucene-queries-4.4.0.jar",
|
||||
"/opt/elasticsearch-0.90.5/lib/lucene-queryparser-4.4.0.jar",
|
||||
"/opt/elasticsearch-0.90.5/lib/lucene-sandbox-4.4.0.jar",
|
||||
"/opt/elasticsearch-0.90.5/lib/lucene-spatial-4.4.0.jar",
|
||||
"/opt/elasticsearch-0.90.5/lib/lucene-suggest-4.4.0.jar",
|
||||
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-amd64-freebsd-6.so",
|
||||
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-amd64-linux.so",
|
||||
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-amd64-solaris.so",
|
||||
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-ia64-linux.so",
|
||||
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-sparc-solaris.so",
|
||||
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-sparc64-solaris.so",
|
||||
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-universal-macosx.dylib",
|
||||
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-universal64-macosx.dylib",
|
||||
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-x86-freebsd-5.so",
|
||||
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-x86-freebsd-6.so",
|
||||
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-x86-linux.so",
|
||||
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-x86-solaris.so",
|
||||
"/opt/elasticsearch-0.90.5/lib/sigar/sigar-1.6.4.jar",
|
||||
"/opt/elasticsearch-0.90.5/lib/spatial4j-0.3.jar"
|
||||
],
|
||||
"changed": []
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"removed": [],
|
||||
"added": [],
|
||||
"changed": []
|
||||
}
|
544
test/specs.py
Normal file
|
@ -0,0 +1,544 @@
|
|||
import json
|
||||
|
||||
from flask import url_for
|
||||
from collections import OrderedDict
|
||||
from uuid import uuid4
|
||||
from base64 import b64encode
|
||||
|
||||
NO_REPO = None
|
||||
PUBLIC_REPO = 'public/publicrepo'
|
||||
PRIVATE_REPO = 'devtable/shared'
|
||||
|
||||
ORG = 'buynlarge'
|
||||
ORG_REPO = ORG + '/orgrepo'
|
||||
ORG_READERS = 'readers'
|
||||
ORG_OWNER = 'devtable'
|
||||
ORG_OWNERS = 'owners'
|
||||
ORG_READERS = 'readers'
|
||||
|
||||
FAKE_IMAGE_ID = str(uuid4())
|
||||
FAKE_TAG_NAME = str(uuid4())
|
||||
FAKE_USERNAME = str(uuid4())
|
||||
FAKE_TOKEN = str(uuid4())
|
||||
|
||||
NEW_ORG_REPO_DETAILS = {
|
||||
'repository': str(uuid4()),
|
||||
'visibility': 'private',
|
||||
'description': '',
|
||||
'namespace': ORG,
|
||||
}
|
||||
|
||||
NEW_USER_DETAILS = {
|
||||
'username': 'bob',
|
||||
'password': 'password',
|
||||
'email': 'jake@devtable.com',
|
||||
}
|
||||
|
||||
SEND_RECOVERY_DETAILS = {
|
||||
'email': 'jacob.moshenko@gmail.com',
|
||||
}
|
||||
|
||||
SIGNIN_DETAILS = {
|
||||
'username': 'devtable',
|
||||
'password': 'password',
|
||||
}
|
||||
|
||||
FILE_DROP_DETAILS = {
|
||||
'mimeType': 'application/zip',
|
||||
}
|
||||
|
||||
CHANGE_PERMISSION_DETAILS = {
|
||||
'role': 'admin',
|
||||
}
|
||||
|
||||
CREATE_BUILD_DETAILS = {
|
||||
'file_id': str(uuid4()),
|
||||
}
|
||||
|
||||
CHANGE_VISIBILITY_DETAILS = {
|
||||
'visibility': 'public',
|
||||
}
|
||||
|
||||
CREATE_TOKEN_DETAILS = {
|
||||
'friendlyName': 'A new token',
|
||||
}
|
||||
|
||||
UPDATE_REPO_DETAILS = {
|
||||
'description': 'A new description',
|
||||
}
|
||||
|
||||
|
||||
class TestSpec(object):
|
||||
def __init__(self, url, anon_code=401, no_access_code=403, read_code=403,
|
||||
admin_code=200):
|
||||
self._url = url
|
||||
self._data = None
|
||||
self._method = 'GET'
|
||||
|
||||
self.anon_code = anon_code
|
||||
self.no_access_code = no_access_code
|
||||
self.read_code = read_code
|
||||
self.admin_code = admin_code
|
||||
|
||||
def set_data_from_obj(self, json_serializable):
|
||||
self._data = json.dumps(json_serializable)
|
||||
return self
|
||||
|
||||
def set_method(self, method):
|
||||
self._method = method
|
||||
return self
|
||||
|
||||
def get_client_args(self):
|
||||
kwargs = {
|
||||
'method': self._method
|
||||
}
|
||||
|
||||
if self._data or self._method == 'POST' or self._method == 'PUT':
|
||||
kwargs['data'] = self._data if self._data else '{}'
|
||||
kwargs['content_type'] = 'application/json'
|
||||
|
||||
return self._url, kwargs
|
||||
|
||||
|
||||
def build_specs():
|
||||
return [
|
||||
TestSpec(url_for('welcome'), 200, 200, 200, 200),
|
||||
|
||||
TestSpec(url_for('plans_list'), 200, 200, 200, 200),
|
||||
|
||||
TestSpec(url_for('get_logged_in_user'), 200, 200, 200, 200),
|
||||
|
||||
TestSpec(url_for('change_user_details'),
|
||||
401, 200, 200, 200).set_method('PUT'),
|
||||
|
||||
TestSpec(url_for('create_user_api'), 201, 201, 201,
|
||||
201).set_method('POST').set_data_from_obj(NEW_USER_DETAILS),
|
||||
|
||||
TestSpec(url_for('signin_api'), 200, 200, 200,
|
||||
200).set_method('POST').set_data_from_obj(SIGNIN_DETAILS),
|
||||
|
||||
TestSpec(url_for('send_recovery'), 201, 201, 201,
|
||||
201).set_method('POST').set_data_from_obj(SEND_RECOVERY_DETAILS),
|
||||
|
||||
TestSpec(url_for('get_matching_users', prefix='dev'), 401, 200, 200, 200),
|
||||
|
||||
TestSpec(url_for('get_matching_entities', prefix='dev'), 401, 200, 200,
|
||||
200),
|
||||
|
||||
TestSpec(url_for('get_organization', orgname=ORG), 401, 403, 200, 200),
|
||||
|
||||
TestSpec(url_for('get_organization_private_allowed', orgname=ORG)),
|
||||
|
||||
TestSpec(url_for('update_organization_team', orgname=ORG,
|
||||
teamname=ORG_OWNERS)).set_method('PUT'),
|
||||
TestSpec(url_for('update_organization_team', orgname=ORG,
|
||||
teamname=ORG_READERS)).set_method('PUT'),
|
||||
|
||||
TestSpec(url_for('delete_organization_team', orgname=ORG,
|
||||
teamname=ORG_OWNERS),
|
||||
admin_code=400).set_method('DELETE'),
|
||||
TestSpec(url_for('delete_organization_team', orgname=ORG,
|
||||
teamname=ORG_READERS),
|
||||
admin_code=204).set_method('DELETE'),
|
||||
|
||||
TestSpec(url_for('get_organization_team_members', orgname=ORG,
|
||||
teamname=ORG_OWNERS)),
|
||||
TestSpec(url_for('get_organization_team_members', orgname=ORG,
|
||||
teamname=ORG_READERS), read_code=200),
|
||||
|
||||
TestSpec(url_for('update_organization_team_member', orgname=ORG,
|
||||
teamname=ORG_OWNERS, membername=ORG_OWNER),
|
||||
admin_code=400).set_method('PUT'),
|
||||
TestSpec(url_for('update_organization_team_member', orgname=ORG,
|
||||
teamname=ORG_READERS,
|
||||
membername=ORG_OWNER)).set_method('PUT'),
|
||||
|
||||
TestSpec(url_for('delete_organization_team_member', orgname=ORG,
|
||||
teamname=ORG_OWNERS, membername=ORG_OWNER),
|
||||
admin_code=400).set_method('DELETE'),
|
||||
TestSpec(url_for('delete_organization_team_member', orgname=ORG,
|
||||
teamname=ORG_READERS, membername=ORG_OWNER),
|
||||
admin_code=400).set_method('DELETE'),
|
||||
|
||||
(TestSpec(url_for('create_repo_api'))
|
||||
.set_method('POST')
|
||||
.set_data_from_obj(NEW_ORG_REPO_DETAILS)),
|
||||
|
||||
TestSpec(url_for('match_repos_api'), 200, 200, 200, 200),
|
||||
|
||||
TestSpec(url_for('list_repos_api'), 200, 200, 200, 200),
|
||||
|
||||
TestSpec(url_for('update_repo_api', repository=PUBLIC_REPO),
|
||||
admin_code=403).set_method('PUT'),
|
||||
(TestSpec(url_for('update_repo_api', repository=ORG_REPO))
|
||||
.set_method('PUT')
|
||||
.set_data_from_obj(UPDATE_REPO_DETAILS)),
|
||||
(TestSpec(url_for('update_repo_api', repository=PRIVATE_REPO))
|
||||
.set_method('PUT')
|
||||
.set_data_from_obj(UPDATE_REPO_DETAILS)),
|
||||
|
||||
(TestSpec(url_for('change_repo_visibility_api', repository=PUBLIC_REPO),
|
||||
admin_code=403).set_method('POST')
|
||||
.set_data_from_obj(CHANGE_VISIBILITY_DETAILS)),
|
||||
(TestSpec(url_for('change_repo_visibility_api', repository=ORG_REPO))
|
||||
.set_method('POST').set_data_from_obj(CHANGE_VISIBILITY_DETAILS)),
|
||||
(TestSpec(url_for('change_repo_visibility_api', repository=PRIVATE_REPO))
|
||||
.set_method('POST').set_data_from_obj(CHANGE_VISIBILITY_DETAILS)),
|
||||
|
||||
TestSpec(url_for('delete_repository', repository=PUBLIC_REPO),
|
||||
admin_code=403).set_method('DELETE'),
|
||||
TestSpec(url_for('delete_repository', repository=ORG_REPO),
|
||||
admin_code=204).set_method('DELETE'),
|
||||
TestSpec(url_for('delete_repository', repository=PRIVATE_REPO),
|
||||
admin_code=204).set_method('DELETE'),
|
||||
|
||||
TestSpec(url_for('get_repo_api', repository=PUBLIC_REPO),
|
||||
200, 200, 200,200),
|
||||
TestSpec(url_for('get_repo_api', repository=ORG_REPO),
|
||||
403, 403, 200, 200),
|
||||
TestSpec(url_for('get_repo_api', repository=PRIVATE_REPO),
|
||||
403, 403, 200, 200),
|
||||
|
||||
TestSpec(url_for('get_repo_builds', repository=PUBLIC_REPO),
|
||||
admin_code=403),
|
||||
TestSpec(url_for('get_repo_builds', repository=ORG_REPO)),
|
||||
TestSpec(url_for('get_repo_builds', repository=PRIVATE_REPO)),
|
||||
|
||||
TestSpec(url_for('get_filedrop_url'), 401, 200, 200,
|
||||
200).set_method('POST').set_data_from_obj(FILE_DROP_DETAILS),
|
||||
|
||||
(TestSpec(url_for('request_repo_build', repository=PUBLIC_REPO),
|
||||
admin_code=403).set_method('POST')
|
||||
.set_data_from_obj(CREATE_BUILD_DETAILS)),
|
||||
(TestSpec(url_for('request_repo_build', repository=ORG_REPO),
|
||||
admin_code=201).set_method('POST')
|
||||
.set_data_from_obj(CREATE_BUILD_DETAILS)),
|
||||
(TestSpec(url_for('request_repo_build', repository=PRIVATE_REPO),
|
||||
admin_code=201).set_method('POST')
|
||||
.set_data_from_obj(CREATE_BUILD_DETAILS)),
|
||||
|
||||
TestSpec(url_for('list_repository_images', repository=PUBLIC_REPO),
|
||||
200, 200, 200, 200),
|
||||
TestSpec(url_for('list_repository_images', repository=ORG_REPO),
|
||||
403, 403, 200, 200),
|
||||
TestSpec(url_for('list_repository_images', repository=PRIVATE_REPO),
|
||||
403, 403, 200, 200),
|
||||
|
||||
TestSpec(url_for('get_image', repository=PUBLIC_REPO,
|
||||
image_id=FAKE_IMAGE_ID), 404, 404, 404, 404),
|
||||
TestSpec(url_for('get_image', repository=ORG_REPO,
|
||||
image_id=FAKE_IMAGE_ID), 403, 403, 404, 404),
|
||||
TestSpec(url_for('get_image', repository=PRIVATE_REPO,
|
||||
image_id=FAKE_IMAGE_ID), 403, 403, 404, 404),
|
||||
|
||||
TestSpec(url_for('get_image_changes', repository=PUBLIC_REPO,
|
||||
image_id=FAKE_IMAGE_ID), 404, 404, 404, 404),
|
||||
TestSpec(url_for('get_image_changes', repository=ORG_REPO,
|
||||
image_id=FAKE_IMAGE_ID), 403, 403, 404, 404),
|
||||
TestSpec(url_for('get_image_changes', repository=PRIVATE_REPO,
|
||||
image_id=FAKE_IMAGE_ID), 403, 403, 404, 404),
|
||||
|
||||
TestSpec(url_for('list_tag_images', repository=PUBLIC_REPO,
|
||||
tag=FAKE_TAG_NAME), 404, 404, 404, 404),
|
||||
TestSpec(url_for('list_tag_images', repository=ORG_REPO,
|
||||
tag=FAKE_TAG_NAME), 403, 403, 404, 404),
|
||||
TestSpec(url_for('list_tag_images', repository=PRIVATE_REPO,
|
||||
tag=FAKE_TAG_NAME), 403, 403, 404, 404),
|
||||
|
||||
TestSpec(url_for('list_repo_team_permissions', repository=PUBLIC_REPO),
|
||||
admin_code=403),
|
||||
TestSpec(url_for('list_repo_team_permissions', repository=ORG_REPO)),
|
||||
TestSpec(url_for('list_repo_team_permissions', repository=PRIVATE_REPO)),
|
||||
|
||||
TestSpec(url_for('list_repo_user_permissions', repository=PUBLIC_REPO),
|
||||
admin_code=403),
|
||||
TestSpec(url_for('list_repo_user_permissions', repository=ORG_REPO)),
|
||||
TestSpec(url_for('list_repo_user_permissions', repository=PRIVATE_REPO)),
|
||||
|
||||
TestSpec(url_for('get_user_permissions', repository=PUBLIC_REPO,
|
||||
username=FAKE_USERNAME), admin_code=403),
|
||||
TestSpec(url_for('get_user_permissions', repository=ORG_REPO,
|
||||
username=FAKE_USERNAME), admin_code=400),
|
||||
TestSpec(url_for('get_user_permissions', repository=PRIVATE_REPO,
|
||||
username=FAKE_USERNAME), admin_code=400),
|
||||
|
||||
TestSpec(url_for('get_team_permissions', repository=PUBLIC_REPO,
|
||||
teamname=ORG_OWNERS), admin_code=403),
|
||||
TestSpec(url_for('get_team_permissions', repository=PUBLIC_REPO,
|
||||
teamname=ORG_READERS), admin_code=403),
|
||||
TestSpec(url_for('get_team_permissions', repository=ORG_REPO,
|
||||
teamname=ORG_OWNERS), admin_code=400),
|
||||
TestSpec(url_for('get_team_permissions', repository=ORG_REPO,
|
||||
teamname=ORG_READERS)),
|
||||
TestSpec(url_for('get_team_permissions', repository=PRIVATE_REPO,
|
||||
teamname=ORG_OWNERS), admin_code=400),
|
||||
TestSpec(url_for('get_team_permissions', repository=PRIVATE_REPO,
|
||||
teamname=ORG_READERS), admin_code=400),
|
||||
|
||||
TestSpec(url_for('change_user_permissions', repository=PUBLIC_REPO,
|
||||
username=FAKE_USERNAME),
|
||||
admin_code=403).set_method('PUT'),
|
||||
TestSpec(url_for('change_user_permissions', repository=ORG_REPO,
|
||||
username=FAKE_USERNAME),
|
||||
admin_code=400).set_method('PUT'),
|
||||
TestSpec(url_for('change_user_permissions', repository=PRIVATE_REPO,
|
||||
username=FAKE_USERNAME),
|
||||
admin_code=400).set_method('PUT'),
|
||||
|
||||
(TestSpec(url_for('change_team_permissions', repository=PUBLIC_REPO,
|
||||
teamname=ORG_OWNERS), admin_code=403)
|
||||
.set_method('PUT')
|
||||
.set_data_from_obj(CHANGE_PERMISSION_DETAILS)),
|
||||
(TestSpec(url_for('change_team_permissions', repository=PUBLIC_REPO,
|
||||
teamname=ORG_READERS), admin_code=403)
|
||||
.set_method('PUT')
|
||||
.set_data_from_obj(CHANGE_PERMISSION_DETAILS)),
|
||||
(TestSpec(url_for('change_team_permissions', repository=ORG_REPO,
|
||||
teamname=ORG_OWNERS))
|
||||
.set_method('PUT')
|
||||
.set_data_from_obj(CHANGE_PERMISSION_DETAILS)),
|
||||
(TestSpec(url_for('change_team_permissions', repository=ORG_REPO,
|
||||
teamname=ORG_READERS))
|
||||
.set_method('PUT')
|
||||
.set_data_from_obj(CHANGE_PERMISSION_DETAILS)),
|
||||
(TestSpec(url_for('change_team_permissions', repository=PRIVATE_REPO,
|
||||
teamname=ORG_OWNERS), admin_code=400)
|
||||
.set_method('PUT')
|
||||
.set_data_from_obj(CHANGE_PERMISSION_DETAILS)),
|
||||
(TestSpec(url_for('change_team_permissions', repository=PRIVATE_REPO,
|
||||
teamname=ORG_READERS), admin_code=400)
|
||||
.set_method('PUT')
|
||||
.set_data_from_obj(CHANGE_PERMISSION_DETAILS)),
|
||||
|
||||
TestSpec(url_for('delete_user_permissions', repository=PUBLIC_REPO,
|
||||
username=FAKE_USERNAME),
|
||||
admin_code=403).set_method('DELETE'),
|
||||
TestSpec(url_for('delete_user_permissions', repository=ORG_REPO,
|
||||
username=FAKE_USERNAME),
|
||||
admin_code=400).set_method('DELETE'),
|
||||
TestSpec(url_for('delete_user_permissions', repository=PRIVATE_REPO,
|
||||
username=FAKE_USERNAME),
|
||||
admin_code=400).set_method('DELETE'),
|
||||
|
||||
TestSpec(url_for('delete_team_permissions', repository=PUBLIC_REPO,
|
||||
teamname=ORG_OWNERS),
|
||||
admin_code=403).set_method('DELETE'),
|
||||
TestSpec(url_for('delete_team_permissions', repository=PUBLIC_REPO,
|
||||
teamname=ORG_READERS),
|
||||
admin_code=403).set_method('DELETE'),
|
||||
TestSpec(url_for('delete_team_permissions', repository=ORG_REPO,
|
||||
teamname=ORG_OWNERS),
|
||||
admin_code=400).set_method('DELETE'),
|
||||
TestSpec(url_for('delete_team_permissions', repository=ORG_REPO,
|
||||
teamname=ORG_READERS),
|
||||
admin_code=204).set_method('DELETE'),
|
||||
TestSpec(url_for('delete_team_permissions', repository=PRIVATE_REPO,
|
||||
teamname=ORG_OWNERS),
|
||||
admin_code=400).set_method('DELETE'),
|
||||
TestSpec(url_for('delete_team_permissions', repository=PRIVATE_REPO,
|
||||
teamname=ORG_READERS),
|
||||
admin_code=400).set_method('DELETE'),
|
||||
|
||||
TestSpec(url_for('list_repo_tokens', repository=PUBLIC_REPO),
|
||||
admin_code=403),
|
||||
TestSpec(url_for('list_repo_tokens', repository=ORG_REPO)),
|
||||
TestSpec(url_for('list_repo_tokens', repository=PRIVATE_REPO)),
|
||||
|
||||
TestSpec(url_for('get_tokens', repository=PUBLIC_REPO, code=FAKE_TOKEN),
|
||||
admin_code=403),
|
||||
TestSpec(url_for('get_tokens', repository=ORG_REPO, code=FAKE_TOKEN),
|
||||
admin_code=400),
|
||||
TestSpec(url_for('get_tokens', repository=PRIVATE_REPO, code=FAKE_TOKEN),
|
||||
admin_code=400),
|
||||
|
||||
TestSpec(url_for('create_token', repository=PUBLIC_REPO),
|
||||
admin_code=403).set_method('POST'),
|
||||
(TestSpec(url_for('create_token', repository=ORG_REPO),
|
||||
admin_code=201).set_method('POST')
|
||||
.set_data_from_obj(CREATE_TOKEN_DETAILS)),
|
||||
(TestSpec(url_for('create_token', repository=PRIVATE_REPO),
|
||||
admin_code=201).set_method('POST')
|
||||
.set_data_from_obj(CREATE_TOKEN_DETAILS)),
|
||||
|
||||
TestSpec(url_for('change_token', repository=PUBLIC_REPO, code=FAKE_TOKEN),
|
||||
admin_code=403).set_method('PUT'),
|
||||
TestSpec(url_for('change_token', repository=ORG_REPO, code=FAKE_TOKEN),
|
||||
admin_code=400).set_method('PUT'),
|
||||
TestSpec(url_for('change_token', repository=PRIVATE_REPO,
|
||||
code=FAKE_TOKEN), admin_code=400).set_method('PUT'),
|
||||
|
||||
TestSpec(url_for('delete_token', repository=PUBLIC_REPO, code=FAKE_TOKEN),
|
||||
admin_code=403).set_method('DELETE'),
|
||||
TestSpec(url_for('delete_token', repository=ORG_REPO, code=FAKE_TOKEN),
|
||||
admin_code=400).set_method('DELETE'),
|
||||
TestSpec(url_for('delete_token', repository=PRIVATE_REPO,
|
||||
code=FAKE_TOKEN), admin_code=400).set_method('DELETE'),
|
||||
|
||||
TestSpec(url_for('subscribe_api'), 401, 400, 400, 400).set_method('PUT'),
|
||||
|
||||
TestSpec(url_for('subscribe_org_api', orgname=ORG),
|
||||
401, 403, 403, 400).set_method('PUT'),
|
||||
|
||||
TestSpec(url_for('get_subscription'), 401, 200, 200, 200),
|
||||
|
||||
TestSpec(url_for('get_org_subscription', orgname=ORG)),
|
||||
]
|
||||
|
||||
|
||||
class IndexTestSpec(object):
|
||||
def __init__(self, url, sess_repo=None, anon_code=403, no_access_code=403,
|
||||
read_code=200, admin_code=200):
|
||||
self._url = url
|
||||
self._method = 'GET'
|
||||
self._data = None
|
||||
|
||||
self.sess_repo = sess_repo
|
||||
|
||||
self.anon_code = anon_code
|
||||
self.no_access_code = no_access_code
|
||||
self.read_code = read_code
|
||||
self.admin_code = admin_code
|
||||
|
||||
def gen_basic_auth(self, username, password):
|
||||
encoded = b64encode('%s:%s' % (username, password))
|
||||
return 'basic %s' % encoded
|
||||
|
||||
def set_data_from_obj(self, json_serializable):
|
||||
self._data = json.dumps(json_serializable)
|
||||
return self
|
||||
|
||||
def set_method(self, method):
|
||||
self._method = method
|
||||
return self
|
||||
|
||||
def get_client_args(self):
|
||||
kwargs = {
|
||||
'method': self._method
|
||||
}
|
||||
|
||||
if self._data or self._method == 'POST' or self._method == 'PUT':
|
||||
kwargs['data'] = self._data if self._data else '{}'
|
||||
kwargs['content_type'] = 'application/json'
|
||||
|
||||
return self._url, kwargs
|
||||
|
||||
|
||||
def build_index_specs():
|
||||
return [
|
||||
IndexTestSpec(url_for('get_image_layer', image_id=FAKE_IMAGE_ID),
|
||||
PUBLIC_REPO, 200, 200, 200, 200),
|
||||
IndexTestSpec(url_for('get_image_layer', image_id=FAKE_IMAGE_ID),
|
||||
PRIVATE_REPO),
|
||||
IndexTestSpec(url_for('get_image_layer', image_id=FAKE_IMAGE_ID),
|
||||
ORG_REPO),
|
||||
|
||||
IndexTestSpec(url_for('put_image_layer', image_id=FAKE_IMAGE_ID),
|
||||
PUBLIC_REPO, 403, 403, 403, 403).set_method('PUT'),
|
||||
IndexTestSpec(url_for('put_image_layer', image_id=FAKE_IMAGE_ID),
|
||||
PRIVATE_REPO, 403, 403, 403, 404).set_method('PUT'),
|
||||
IndexTestSpec(url_for('put_image_layer', image_id=FAKE_IMAGE_ID),
|
||||
ORG_REPO, 403, 403, 403, 404).set_method('PUT'),
|
||||
|
||||
IndexTestSpec(url_for('put_image_checksum', image_id=FAKE_IMAGE_ID),
|
||||
PUBLIC_REPO, 403, 403, 403, 403).set_method('PUT'),
|
||||
IndexTestSpec(url_for('put_image_checksum', image_id=FAKE_IMAGE_ID),
|
||||
PRIVATE_REPO, 403, 403, 403, 400).set_method('PUT'),
|
||||
IndexTestSpec(url_for('put_image_checksum', image_id=FAKE_IMAGE_ID),
|
||||
ORG_REPO, 403, 403, 403, 400).set_method('PUT'),
|
||||
|
||||
IndexTestSpec(url_for('get_image_json', image_id=FAKE_IMAGE_ID),
|
||||
PUBLIC_REPO, 404, 404, 404, 404),
|
||||
IndexTestSpec(url_for('get_image_json', image_id=FAKE_IMAGE_ID),
|
||||
PRIVATE_REPO, 403, 403, 404, 404),
|
||||
IndexTestSpec(url_for('get_image_json', image_id=FAKE_IMAGE_ID),
|
||||
ORG_REPO, 403, 403, 404, 404),
|
||||
|
||||
IndexTestSpec(url_for('get_image_ancestry', image_id=FAKE_IMAGE_ID),
|
||||
PUBLIC_REPO, 404, 404, 404, 404),
|
||||
IndexTestSpec(url_for('get_image_ancestry', image_id=FAKE_IMAGE_ID),
|
||||
PRIVATE_REPO, 403, 403, 404, 404),
|
||||
IndexTestSpec(url_for('get_image_ancestry', image_id=FAKE_IMAGE_ID),
|
||||
ORG_REPO, 403, 403, 404, 404),
|
||||
|
||||
IndexTestSpec(url_for('put_image_json', image_id=FAKE_IMAGE_ID),
|
||||
PUBLIC_REPO, 403, 403, 403, 403).set_method('PUT'),
|
||||
IndexTestSpec(url_for('put_image_json', image_id=FAKE_IMAGE_ID),
|
||||
PRIVATE_REPO, 403, 403, 403, 400).set_method('PUT'),
|
||||
IndexTestSpec(url_for('put_image_json', image_id=FAKE_IMAGE_ID),
|
||||
ORG_REPO, 403, 403, 403, 400).set_method('PUT'),
|
||||
|
||||
IndexTestSpec(url_for('create_user'), NO_REPO, 201, 201, 201,
|
||||
201).set_method('POST').set_data_from_obj(NEW_USER_DETAILS),
|
||||
|
||||
IndexTestSpec(url_for('get_user'), NO_REPO, 404, 200, 200, 200),
|
||||
|
||||
IndexTestSpec(url_for('update_user', username=FAKE_USERNAME),
|
||||
NO_REPO, 403, 403, 403, 403).set_method('PUT'),
|
||||
|
||||
IndexTestSpec(url_for('create_repository', repository=PUBLIC_REPO),
|
||||
NO_REPO, 403, 403, 403, 403).set_method('PUT'),
|
||||
IndexTestSpec(url_for('create_repository', repository=PRIVATE_REPO),
|
||||
NO_REPO, 403, 403, 403, 201).set_method('PUT'),
|
||||
IndexTestSpec(url_for('create_repository', repository=ORG_REPO),
|
||||
NO_REPO, 403, 403, 403, 201).set_method('PUT'),
|
||||
|
||||
IndexTestSpec(url_for('update_images', repository=PUBLIC_REPO), NO_REPO,
|
||||
403, 403, 403, 403).set_method('PUT'),
|
||||
IndexTestSpec(url_for('update_images', repository=PRIVATE_REPO), NO_REPO,
|
||||
403, 403, 403, 204).set_method('PUT'),
|
||||
IndexTestSpec(url_for('update_images', repository=ORG_REPO), NO_REPO,
|
||||
403, 403, 403, 204).set_method('PUT'),
|
||||
|
||||
IndexTestSpec(url_for('get_repository_images', repository=PUBLIC_REPO),
|
||||
NO_REPO, 200, 200, 200, 200),
|
||||
IndexTestSpec(url_for('get_repository_images', repository=PRIVATE_REPO)),
|
||||
IndexTestSpec(url_for('get_repository_images', repository=ORG_REPO)),
|
||||
|
||||
IndexTestSpec(url_for('delete_repository_images', repository=PUBLIC_REPO),
|
||||
NO_REPO, 501, 501, 501, 501).set_method('DELETE'),
|
||||
|
||||
IndexTestSpec(url_for('put_repository_auth', repository=PUBLIC_REPO),
|
||||
NO_REPO, 501, 501, 501, 501).set_method('PUT'),
|
||||
|
||||
IndexTestSpec(url_for('get_search'), NO_REPO, 501, 501, 501, 501),
|
||||
|
||||
IndexTestSpec(url_for('ping'), NO_REPO, 200, 200, 200, 200),
|
||||
|
||||
IndexTestSpec(url_for('get_tags', repository=PUBLIC_REPO), NO_REPO,
|
||||
200, 200, 200, 200),
|
||||
IndexTestSpec(url_for('get_tags', repository=PRIVATE_REPO)),
|
||||
IndexTestSpec(url_for('get_tags', repository=ORG_REPO)),
|
||||
|
||||
IndexTestSpec(url_for('get_tag', repository=PUBLIC_REPO,
|
||||
tag=FAKE_TAG_NAME), NO_REPO, 400, 400, 400, 400),
|
||||
IndexTestSpec(url_for('get_tag', repository=PRIVATE_REPO,
|
||||
tag=FAKE_TAG_NAME), NO_REPO, 403, 403, 400, 400),
|
||||
IndexTestSpec(url_for('get_tag', repository=ORG_REPO,
|
||||
tag=FAKE_TAG_NAME), NO_REPO, 403, 403, 400, 400),
|
||||
|
||||
IndexTestSpec(url_for('put_tag', repository=PUBLIC_REPO,
|
||||
tag=FAKE_TAG_NAME),
|
||||
NO_REPO, 403, 403, 403, 403).set_method('PUT'),
|
||||
IndexTestSpec(url_for('put_tag', repository=PRIVATE_REPO,
|
||||
tag=FAKE_TAG_NAME),
|
||||
NO_REPO, 403, 403, 403, 400).set_method('PUT'),
|
||||
IndexTestSpec(url_for('put_tag', repository=ORG_REPO, tag=FAKE_TAG_NAME),
|
||||
NO_REPO, 403, 403, 403, 400).set_method('PUT'),
|
||||
|
||||
IndexTestSpec(url_for('delete_tag', repository=PUBLIC_REPO,
|
||||
tag=FAKE_TAG_NAME),
|
||||
NO_REPO, 403, 403, 403, 403).set_method('DELETE'),
|
||||
IndexTestSpec(url_for('delete_tag', repository=PRIVATE_REPO,
|
||||
tag=FAKE_TAG_NAME),
|
||||
NO_REPO, 403, 403, 403, 400).set_method('DELETE'),
|
||||
IndexTestSpec(url_for('delete_tag', repository=ORG_REPO,
|
||||
tag=FAKE_TAG_NAME),
|
||||
NO_REPO, 403, 403, 403, 400).set_method('DELETE'),
|
||||
|
||||
IndexTestSpec(url_for('delete_repository_tags', repository=PUBLIC_REPO),
|
||||
NO_REPO, 403, 403, 403, 403).set_method('DELETE'),
|
||||
IndexTestSpec(url_for('delete_repository_tags', repository=PRIVATE_REPO),
|
||||
NO_REPO, 403, 403, 403, 204).set_method('DELETE'),
|
||||
IndexTestSpec(url_for('delete_repository_tags', repository=ORG_REPO),
|
||||
NO_REPO, 403, 403, 403, 204).set_method('DELETE'),
|
||||
]
|
95
test/test_api_security.py
Normal file
|
@ -0,0 +1,95 @@
|
|||
import unittest
|
||||
import json
|
||||
|
||||
import endpoints.api
|
||||
|
||||
from app import app
|
||||
from initdb import wipe_database, initialize_database, populate_database
|
||||
from specs import build_specs
|
||||
|
||||
|
||||
NO_ACCESS_USER = 'freshuser'
|
||||
READ_ACCESS_USER = 'reader'
|
||||
ADMIN_ACCESS_USER = 'devtable'
|
||||
|
||||
|
||||
class ApiTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
wipe_database()
|
||||
initialize_database()
|
||||
populate_database()
|
||||
|
||||
|
||||
class _SpecTestBuilder(type):
|
||||
@staticmethod
|
||||
def _test_generator(url, expected_status, open_kwargs, auth_username=None):
|
||||
def test(self):
|
||||
with app.test_client() as c:
|
||||
if auth_username:
|
||||
# Temporarily remove the teardown functions
|
||||
teardown_funcs = app.teardown_request_funcs[None]
|
||||
app.teardown_request_funcs[None] = []
|
||||
|
||||
with c.session_transaction() as sess:
|
||||
sess['user_id'] = auth_username
|
||||
sess['identity.id'] = auth_username
|
||||
sess['identity.auth_type'] = 'username'
|
||||
|
||||
# Restore the teardown functions
|
||||
app.teardown_request_funcs[None] = teardown_funcs
|
||||
|
||||
rv = c.open(url, **open_kwargs)
|
||||
msg = '%s %s: %s expected: %s' % (open_kwargs['method'], url,
|
||||
rv.status_code, expected_status)
|
||||
self.assertEqual(rv.status_code, expected_status, msg)
|
||||
return test
|
||||
|
||||
|
||||
def __new__(cls, name, bases, attrs):
|
||||
with app.test_request_context() as ctx:
|
||||
specs = attrs['spec_func']()
|
||||
for test_spec in specs:
|
||||
url, open_kwargs = test_spec.get_client_args()
|
||||
expected_status = getattr(test_spec, attrs['result_attr'])
|
||||
test = _SpecTestBuilder._test_generator(url, expected_status,
|
||||
open_kwargs,
|
||||
attrs['auth_username'])
|
||||
|
||||
test_name_url = url.replace('/', '_').replace('-', '_')
|
||||
test_name = 'test_%s_%s' % (open_kwargs['method'].lower(),
|
||||
test_name_url)
|
||||
attrs[test_name] = test
|
||||
|
||||
return type(name, bases, attrs)
|
||||
|
||||
|
||||
class TestAnonymousAccess(ApiTestCase):
|
||||
__metaclass__ = _SpecTestBuilder
|
||||
spec_func = build_specs
|
||||
result_attr = 'anon_code'
|
||||
auth_username = None
|
||||
|
||||
|
||||
class TestNoAccess(ApiTestCase):
|
||||
__metaclass__ = _SpecTestBuilder
|
||||
spec_func = build_specs
|
||||
result_attr = 'no_access_code'
|
||||
auth_username = NO_ACCESS_USER
|
||||
|
||||
|
||||
class TestReadAccess(ApiTestCase):
|
||||
__metaclass__ = _SpecTestBuilder
|
||||
spec_func = build_specs
|
||||
result_attr = 'read_code'
|
||||
auth_username = READ_ACCESS_USER
|
||||
|
||||
|
||||
class TestAdminAccess(ApiTestCase):
|
||||
__metaclass__ = _SpecTestBuilder
|
||||
spec_func = build_specs
|
||||
result_attr = 'admin_code'
|
||||
auth_username = ADMIN_ACCESS_USER
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
107
test/test_endpoint_security.py
Normal file
|
@ -0,0 +1,107 @@
|
|||
import unittest
|
||||
|
||||
import endpoints.registry
|
||||
import endpoints.index
|
||||
import endpoints.tags
|
||||
|
||||
from app import app
|
||||
from util.names import parse_namespace_repository
|
||||
from initdb import wipe_database, initialize_database, populate_database
|
||||
from specs import build_index_specs
|
||||
|
||||
|
||||
NO_ACCESS_USER = 'freshuser'
|
||||
READ_ACCESS_USER = 'reader'
|
||||
ADMIN_ACCESS_USER = 'devtable'
|
||||
|
||||
|
||||
class EndpointTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
wipe_database()
|
||||
initialize_database()
|
||||
populate_database()
|
||||
|
||||
|
||||
class _SpecTestBuilder(type):
|
||||
@staticmethod
|
||||
def _test_generator(url, expected_status, open_kwargs, session_var_list):
|
||||
def test(self):
|
||||
with app.test_client() as c:
|
||||
if session_var_list:
|
||||
# Temporarily remove the teardown functions
|
||||
teardown_funcs = app.teardown_request_funcs[None]
|
||||
app.teardown_request_funcs[None] = []
|
||||
|
||||
with c.session_transaction() as sess:
|
||||
for sess_key, sess_val in session_var_list:
|
||||
sess[sess_key] = sess_val
|
||||
|
||||
# Restore the teardown functions
|
||||
app.teardown_request_funcs[None] = teardown_funcs
|
||||
|
||||
rv = c.open(url, **open_kwargs)
|
||||
msg = '%s %s: %s expected: %s' % (open_kwargs['method'], url,
|
||||
rv.status_code, expected_status)
|
||||
if rv.status_code != expected_status:
|
||||
print msg
|
||||
self.assertEqual(rv.status_code, expected_status, msg)
|
||||
return test
|
||||
|
||||
|
||||
def __new__(cls, name, bases, attrs):
|
||||
with app.test_request_context() as ctx:
|
||||
specs = attrs['spec_func']()
|
||||
for test_spec in specs:
|
||||
url, open_kwargs = test_spec.get_client_args()
|
||||
|
||||
if attrs['auth_username']:
|
||||
basic_auth = test_spec.gen_basic_auth(attrs['auth_username'],
|
||||
'password')
|
||||
open_kwargs['headers'] = [('authorization', '%s' % basic_auth)]
|
||||
|
||||
session_vars = []
|
||||
if test_spec.sess_repo:
|
||||
ns, repo = parse_namespace_repository(test_spec.sess_repo)
|
||||
session_vars.append(('namespace', ns))
|
||||
session_vars.append(('repository', repo))
|
||||
|
||||
expected_status = getattr(test_spec, attrs['result_attr'])
|
||||
test = _SpecTestBuilder._test_generator(url, expected_status,
|
||||
open_kwargs,
|
||||
session_vars)
|
||||
|
||||
test_name_url = url.replace('/', '_').replace('-', '_')
|
||||
sess_repo = str(test_spec.sess_repo).replace('/', '_')
|
||||
test_name = 'test_%s%s_%s' % (open_kwargs['method'].lower(),
|
||||
test_name_url, sess_repo)
|
||||
attrs[test_name] = test
|
||||
|
||||
return type(name, bases, attrs)
|
||||
|
||||
|
||||
class TestAnonymousAccess(EndpointTestCase):
|
||||
__metaclass__ = _SpecTestBuilder
|
||||
spec_func = build_index_specs
|
||||
result_attr = 'anon_code'
|
||||
auth_username = None
|
||||
|
||||
|
||||
class TestNoAccess(EndpointTestCase):
|
||||
__metaclass__ = _SpecTestBuilder
|
||||
spec_func = build_index_specs
|
||||
result_attr = 'no_access_code'
|
||||
auth_username = NO_ACCESS_USER
|
||||
|
||||
|
||||
class TestReadAccess(EndpointTestCase):
|
||||
__metaclass__ = _SpecTestBuilder
|
||||
spec_func = build_index_specs
|
||||
result_attr = 'read_code'
|
||||
auth_username = READ_ACCESS_USER
|
||||
|
||||
|
||||
class TestAdminAccess(EndpointTestCase):
|
||||
__metaclass__ = _SpecTestBuilder
|
||||
spec_func = build_index_specs
|
||||
result_attr = 'admin_code'
|
||||
auth_username = ADMIN_ACCESS_USER
|
37
test/teststorage.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
from uuid import uuid4
|
||||
|
||||
from storage.basestorage import Storage
|
||||
|
||||
|
||||
class FakeStorage(Storage):
|
||||
def _init_path(self, path=None, create=False):
|
||||
return path
|
||||
|
||||
def get_content(self, path):
|
||||
raise IOError('Fake files are fake!')
|
||||
|
||||
def put_content(self, path, content):
|
||||
return path
|
||||
|
||||
def stream_read(self, path):
|
||||
yield ''
|
||||
|
||||
def stream_write(self, path, fp):
|
||||
pass
|
||||
|
||||
def remove(self, path):
|
||||
pass
|
||||
|
||||
def exists(self, path):
|
||||
return False
|
||||
|
||||
|
||||
class FakeUserfiles(object):
|
||||
def prepare_for_drop(self, mime_type):
|
||||
return ('http://fake/url', uuid4())
|
||||
|
||||
def store_file(self, flask_file):
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_file_url(self, file_id, expires_in=300):
|
||||
return ('http://fake/url')
|
|
@ -13,7 +13,6 @@ from base64 import b64encode
|
|||
from requests.exceptions import ConnectionError
|
||||
|
||||
from data.queue import dockerfile_build_queue
|
||||
from data.userfiles import UserRequestFiles
|
||||
from data import model
|
||||
from data.database import db as db_connection
|
||||
from app import app
|
||||
|
@ -151,9 +150,7 @@ def babysit_builder(request):
|
|||
ssh_client.exec_command(remove_auth_cmd)
|
||||
|
||||
# Prepare the signed resource url the build node can fetch the job from
|
||||
user_files = UserRequestFiles(app.config['AWS_ACCESS_KEY'],
|
||||
app.config['AWS_SECRET_KEY'],
|
||||
app.config['REGISTRY_S3_BUCKET'])
|
||||
user_files = app.config['USERFILES']
|
||||
resource_url = user_files.get_file_url(repository_build.resource_key)
|
||||
|
||||
# Start the build server
|
||||
|
|