diff --git a/README.md b/README.md index 1e658d180..818fc5024 100644 --- a/README.md +++ b/README.md @@ -43,3 +43,16 @@ kill -HUP kill restart daemons ``` + +running the tests: + +``` +STACK=test python -m unittest discover +``` + +generating screenshots: + +``` +cd screenshots +casperjs screenshots.js --d +``` \ No newline at end of file diff --git a/app.py b/app.py index 0ee5dd845..3870e9b93 100644 --- a/app.py +++ b/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) diff --git a/auth/permissions.py b/auth/permissions.py index c0661bbf2..0e1655337 100644 --- a/auth/permissions.py +++ b/auth/permissions.py @@ -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) diff --git a/config.py b/config.py index de81bdfe6..8616f47d4 100644 --- a/config.py +++ b/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, diff --git a/data/database.py b/data/database.py index 31581d443..fc5a68454 100644 --- a/data/database.py +++ b/data/database.py @@ -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] diff --git a/data/model.py b/data/model.py index a46ca1912..cae167acf 100644 --- a/data/model.py +++ b/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) diff --git a/data/plans.py b/data/plans.py new file mode 100644 index 000000000..aa17ed4b7 --- /dev/null +++ b/data/plans.py @@ -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 diff --git a/data/userfiles.py b/data/userfiles.py index b8ddd0d90..6330ec207 100644 --- a/data/userfiles.py +++ b/data/userfiles.py @@ -20,8 +20,7 @@ class S3FileWriteException(Exception): class UserRequestFiles(object): def __init__(self, s3_access_key, s3_secret_key, bucket_name): - self._s3_conn = boto.connect_s3(s3_access_key, s3_secret_key, - is_secure=False) + self._s3_conn = boto.connect_s3(s3_access_key, s3_secret_key) self._bucket_name = bucket_name self._bucket = self._s3_conn.get_bucket(bucket_name) self._access_key = s3_access_key @@ -34,7 +33,8 @@ class UserRequestFiles(object): file_id = str(uuid4()) full_key = os.path.join(self._prefix, file_id) k = Key(self._bucket, full_key) - url = k.generate_url(300, 'PUT', headers={'Content-Type': mime_type}) + url = k.generate_url(300, 'PUT', headers={'Content-Type': mime_type}, + encrypt_key=True) return (url, file_id) def store_file(self, flask_file): @@ -43,7 +43,7 @@ class UserRequestFiles(object): k = Key(self._bucket, full_key) logger.debug('Setting s3 content type to: %s' % flask_file.content_type) k.set_metadata('Content-Type', flask_file.content_type) - bytes_written = k.set_contents_from_file(flask_file) + bytes_written = k.set_contents_from_file(flask_file, encrypt_key=True) if bytes_written == 0: raise S3FileWriteException('Unable to write file to S3') diff --git a/endpoints/api.py b/endpoints/api.py index 853c7498f..bac8d10c9 100644 --- a/endpoints/api.py +++ b/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,29 +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/', 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/', 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//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//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//team/', + 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//team/', + 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//team//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//team//members/', + 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//team//members/', + 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'] - visibility = request.get_json()['visibility'] + permission = CreateRepositoryPermission(namespace_name) + if permission.can(): + repository_name = json['repository'] + visibility = json['visibility'] - repo = model.create_repository(namespace_name, repository_name, owner, - visibility) - repo.description = request.get_json()['description'] - repo.save() + existing = model.get_repository(namespace_name, repository_name) + if existing: + return make_response('Repository already exists', 400) - return jsonify({ - 'namespace': namespace_name, - 'name': repository_name - }) + visibility = json['visibility'] + + repo = model.create_repository(namespace_name, repository_name, owner, + visibility) + repo.description = json['description'] + repo.save() + + return jsonify({ + 'namespace': namespace_name, + 'name': repository_name + }) + + abort(403) @app.route('/api/find/repository', methods=['GET']) @@ -226,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') @@ -255,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 @@ -279,7 +642,7 @@ def update_repo_api(namespace, repository): 'success': True }) - abort(404) + abort(403) @app.route('/api/repository//changevisibility', @@ -297,7 +660,7 @@ def change_repo_visibility_api(namespace, repository): 'success': True }) - abort(404) + abort(403) @app.route('/api/repository/', methods=['DELETE']) @@ -310,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): @@ -338,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: @@ -359,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 @@ -400,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) @@ -427,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//image/', methods=['GET']) @parse_repository_name def list_repository_images(namespace, repository): @@ -503,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) @@ -517,42 +899,101 @@ def list_tag_images(namespace, repository, tag): abort(403) # Permission denied -@app.route('/api/repository//permissions/', methods=['GET']) +@app.route('/api/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//permissions/', +@app.route('/api/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//permissions/user/', + 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//permissions/team/', + 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//permissions/', +@app.route('/api/repository//permissions/user/', 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() @@ -560,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//permissions/team/', + 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': @@ -575,19 +1052,35 @@ def change_permissions(namespace, repository, username): abort(403) # Permission denied -@app.route('/api/repository//permissions/', +@app.route('/api/repository//permissions/user/', 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//permissions/team/', + 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 @@ -690,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() @@ -715,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//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(): @@ -747,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//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) diff --git a/endpoints/index.py b/endpoints/index.py index 4920e2b15..00020d317 100644 --- a/endpoints/index.py +++ b/endpoints/index.py @@ -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//', 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//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') diff --git a/endpoints/registry.py b/endpoints/registry.py index b736dc95e..5d71a1f81 100644 --- a/endpoints/registry.py +++ b/endpoints/registry.py @@ -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__) diff --git a/endpoints/tags.py b/endpoints/tags.py index 2607bbde2..41a103975 100644 --- a/endpoints/tags.py +++ b/endpoints/tags.py @@ -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) diff --git a/endpoints/web.py b/endpoints/web.py index ddfbca3d6..9f8712629 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -43,6 +43,7 @@ def load_user(username): @app.route('/', methods=['GET'], defaults={'path': ''}) @app.route('/repository/', methods=['GET']) +@app.route('/organization/', 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') diff --git a/initdb.py b/initdb.py index c5afbdc93..aebdbe684 100644 --- a/initdb.py +++ b/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() diff --git a/screenshots/screenshots.js b/screenshots/screenshots.js index c8cd07b7c..623cbe0fe 100644 --- a/screenshots/screenshots.js +++ b/screenshots/screenshots.js @@ -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(); diff --git a/static/css/quay.css b/static/css/quay.css index 9f5001fc4..59c1638d8 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -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,8 +243,8 @@ color: #444 !important; } -.new-repo .new-header { - font-size: 22px; +.new-repo .new-header .popover { + font-size: 14px; } .new-repo .new-header .repo-circle { @@ -273,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 { @@ -313,7 +399,7 @@ } .plan-price:after { - content: "/ month"; + content: "/ mo"; position: absolute; bottom: 0px; right: 20px; @@ -329,6 +415,10 @@ color: #428bca; } +.plans-list .plan.business-plan .count b { + color: #46ac39; +} + .plans-list .plan .description { font-size: 1em; font-size: 16px; @@ -350,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; } @@ -366,6 +448,10 @@ padding: 20px; } +.landing .popover { + font-size: 14px; +} + .landing { color: white; @@ -567,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; } @@ -603,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; } @@ -859,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; @@ -891,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; } @@ -908,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; @@ -1092,7 +1221,7 @@ p.editable:hover i { } .delete-ui:focus .delete-ui-button { - width: 54px; + width: 60px; } .repo-admin .repo-delete { @@ -1152,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 { @@ -1175,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; @@ -1272,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 { @@ -1301,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, @@ -1481,4 +1954,8 @@ p.editable:hover i { top: -9px; font-weight: bold; font-size: .4em; +} + +.page-description { + margin-bottom: 40px; } \ No newline at end of file diff --git a/static/directives/entity-search.html b/static/directives/entity-search.html new file mode 100644 index 000000000..37e2d1a3f --- /dev/null +++ b/static/directives/entity-search.html @@ -0,0 +1 @@ + diff --git a/static/directives/markdown-input.html b/static/directives/markdown-input.html new file mode 100644 index 000000000..010c2f90a --- /dev/null +++ b/static/directives/markdown-input.html @@ -0,0 +1,31 @@ +
+

+ + (Click to set {{ fieldTitle }}) + +

+ + + +
diff --git a/static/directives/markdown-view.html b/static/directives/markdown-view.html new file mode 100644 index 000000000..fe915b857 --- /dev/null +++ b/static/directives/markdown-view.html @@ -0,0 +1 @@ + diff --git a/static/directives/namespace-selector.html b/static/directives/namespace-selector.html new file mode 100644 index 000000000..98d91394d --- /dev/null +++ b/static/directives/namespace-selector.html @@ -0,0 +1,34 @@ + + + + {{user.username}} + + +
+ + +
+
diff --git a/static/directives/organization-header.html b/static/directives/organization-header.html new file mode 100644 index 000000000..69b51d4ac --- /dev/null +++ b/static/directives/organization-header.html @@ -0,0 +1,18 @@ +
+ + + {{ organization.name }} + + + {{ organization.name }} + + + + / + + + {{ teamName }} + + + +
diff --git a/static/directives/plan-manager.html b/static/directives/plan-manager.html new file mode 100644 index 000000000..cf7612d0d --- /dev/null +++ b/static/directives/plan-manager.html @@ -0,0 +1,62 @@ +
+ + + + +
+ You are using more private repositories than your plan allows. Please + upgrade your subscription to avoid disruptions in your organization's service. +
+ +
+ You are at your current plan's number of allowed private repositories. Please upgrade your subscription to avoid future disruptions in your organization's service. +
+ +
+ 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 organization's service. +
+ + +
+
+ Repository Usage +
+ + + + + + + + + + + + + + + + +
PlanPrivate RepositoriesPrice
{{ plan.title }}{{ plan.privateRepos }}
${{ plan.price / 100 }}
+
+
+ +
+
+ + +
+
+
+
diff --git a/static/directives/plans-table.html b/static/directives/plans-table.html new file mode 100644 index 000000000..3b51df849 --- /dev/null +++ b/static/directives/plans-table.html @@ -0,0 +1,23 @@ +
+ + + + + + + + + + + + + + +
PlanPrivate RepositoriesPrice
{{ plan.title }}{{ plan.privateRepos }}
${{ plan.price / 100 }}
+ + {{ currentPlan == plan ? 'Selected' : 'Choose' }} + +
+
diff --git a/static/directives/role-group.html b/static/directives/role-group.html new file mode 100644 index 000000000..f3b53ad43 --- /dev/null +++ b/static/directives/role-group.html @@ -0,0 +1,5 @@ +
+ +
diff --git a/static/directives/signin-form.html b/static/directives/signin-form.html new file mode 100644 index 000000000..ef0b5f795 --- /dev/null +++ b/static/directives/signin-form.html @@ -0,0 +1,25 @@ + diff --git a/static/img/org-admin.png b/static/img/org-admin.png new file mode 100644 index 000000000..a67852e99 Binary files /dev/null and b/static/img/org-admin.png differ diff --git a/static/img/org-repo-admin.png b/static/img/org-repo-admin.png new file mode 100644 index 000000000..cf9377477 Binary files /dev/null and b/static/img/org-repo-admin.png differ diff --git a/static/img/org-repo-list.png b/static/img/org-repo-list.png new file mode 100644 index 000000000..bcbc081b3 Binary files /dev/null and b/static/img/org-repo-list.png differ diff --git a/static/img/org-teams.png b/static/img/org-teams.png new file mode 100644 index 000000000..e084abeca Binary files /dev/null and b/static/img/org-teams.png differ diff --git a/static/img/quay-icon-stripe.png b/static/img/quay-icon-stripe.png new file mode 100644 index 000000000..cd65245b3 Binary files /dev/null and b/static/img/quay-icon-stripe.png differ diff --git a/static/img/repo-admin.png b/static/img/repo-admin.png index e3b08e1c2..f0511cf7c 100644 Binary files a/static/img/repo-admin.png and b/static/img/repo-admin.png differ diff --git a/static/img/repo-changes.png b/static/img/repo-changes.png index ccf58197d..40a0ce8c2 100644 Binary files a/static/img/repo-changes.png and b/static/img/repo-changes.png differ diff --git a/static/img/repo-view.png b/static/img/repo-view.png index 6a100acef..ea26cd4b5 100644 Binary files a/static/img/repo-view.png and b/static/img/repo-view.png differ diff --git a/static/img/user-home.png b/static/img/user-home.png index 11f95fc78..32aa92d1f 100644 Binary files a/static/img/user-home.png and b/static/img/user-home.png differ diff --git a/static/js/app.js b/static/js/app.js index 9d3e615be..207ef56f0 100644 --- a/static/js/app.js +++ b/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 = '
'; + if (datum.entity.kind == 'user') { + template += ''; + } else if (datum.entity.kind == 'team') { + template += ''; + } + template += '' + datum.value + ''; + + if (datum.entity.is_org_member !== undefined && !datum.entity.is_org_member) { + template += '
This user is outside your organization
'; + } + + template += '
'; + 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) { diff --git a/static/js/controllers.js b/static/js/controllers.js index 0b7a8c3f4..2da04c2a5 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -14,46 +14,12 @@ $.fn.clipboardCopy = function() { }); }; -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 getMarkedDown(string) { - return Markdown.getSanitizingConverter().makeHtml(string || ''); -} - function HeaderCtrl($scope, $location, UserService, Restangular) { - $scope.$watch( function () { return UserService.currentUser(); }, function (currentUser) { + var searchToken = 0; + + $scope.$watch( function () { return UserService.currentUser(); }, function (currentUser) { $scope.user = currentUser; + ++searchToken; }, true); $scope.signout = function() { @@ -77,6 +43,11 @@ function HeaderCtrl($scope, $location, UserService, Restangular) { name: 'repositories', remote: { url: '/api/find/repository?query=%QUERY', + replace: function (url, uriEncodedQuery) { + url = url.replace('%QUERY', uriEncodedQuery); + url += '&cb=' + searchToken; + return url; + }, filter: function(data) { var datums = []; for (var i = 0; i < data.repositories.length; ++i) { @@ -112,35 +83,6 @@ function HeaderCtrl($scope, $location, UserService, Restangular) { } function SigninCtrl($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 landing page - UserService.load(); - $location.path('/'); - }, function(result) { - $scope.needsEmailVerification = result.data.needsEmailVerification; - $scope.invalidCredentials = result.data.invalidCredentials; - }); - - }; - $scope.sendRecovery = function() { var signinPost = Restangular.one('recovery'); signinPost.customPOST($scope.recovery).then(function() { @@ -155,8 +97,12 @@ function SigninCtrl($scope, $location, $timeout, Restangular, KeyService, UserSe $scope.status = 'ready'; }; -function PlansCtrl($scope, UserService, PlanService) { - $scope.plans = PlanService.planList(); +function PlansCtrl($scope, $location, UserService, PlanService) { + // Load the list of plans. + PlanService.getPlans(function(plans) { + $scope.plans = plans; + $scope.status = 'ready'; + }); $scope.$watch( function () { return UserService.currentUser(); }, function (currentUser) { $scope.user = currentUser; @@ -170,7 +116,13 @@ function PlansCtrl($scope, UserService, PlanService) { } }; - $scope.status = 'ready'; + $scope.createOrg = function(plan) { + if ($scope.user && !$scope.user.anonymous) { + document.location = '/organizations/new/?plan=' + plan; + } else { + $('#signinModal').modal({}); + } + }; } function GuideCtrl($scope) { @@ -178,47 +130,60 @@ function GuideCtrl($scope) { } function RepoListCtrl($scope, Restangular, UserService) { + $scope.namespace = null; + $scope.$watch( function () { return UserService.currentUser(); }, function (currentUser) { $scope.user = currentUser; }, true); - $scope.getCommentFirstLine = function(commentString) { - return getMarkedDown(getFirstTextLine(commentString)); - }; - - $scope.getMarkedDown = function(string) { - if (!string) { return ''; } - return getMarkedDown(string); - }; + $scope.$watch('namespace', function(namespace) { + loadMyRepos(namespace); + }); $scope.loading = true; $scope.public_repositories = null; - $scope.private_repositories = null; + $scope.user_repositories = []; - // Load the list of personal repositories. - var repositoryPrivateFetch = Restangular.all('repository/'); - repositoryPrivateFetch.getList({'public': false, 'sort': true}).then(function(resp) { - $scope.private_repositories = resp.repositories; - $scope.loading = !($scope.public_repositories && $scope.private_repositories); - }); + var loadMyRepos = function(namespace) { + if (!$scope.user || $scope.user.anonymous || !namespace) { + return; + } + + $scope.loadingmyrepos = true; + + // Load the list of repositories. + var params = { + 'public': false, + 'sort': true, + 'namespace': namespace + }; + + var repositoryFetch = Restangular.all('repository/'); + repositoryFetch.getList(params).then(function(resp) { + $scope.user_repositories = resp.repositories; + $scope.loading = !($scope.public_repositories && $scope.user_repositories); + }); + }; // Load the list of public repositories. var options = {'public': true, 'private': false, 'sort': true, 'limit': 10}; var repositoryPublicFetch = Restangular.all('repository/'); repositoryPublicFetch.getList(options).then(function(resp) { $scope.public_repositories = resp.repositories; - $scope.loading = !($scope.public_repositories && $scope.private_repositories); + $scope.loading = !($scope.public_repositories && $scope.user_repositories); }); } function LandingCtrl($scope, $timeout, $location, Restangular, UserService, KeyService) { $('.form-signup').popover(); - $scope.$watch( function () { return UserService.currentUser(); }, function (currentUser) { - if (!currentUser.anonymous) { - $scope.loadMyRepos(); - } + $scope.namespace = null; + $scope.$watch('namespace', function(namespace) { + loadMyRepos(namespace); + }); + + $scope.$watch( function () { return UserService.currentUser(); }, function (currentUser) { $scope.user = currentUser; }, true); @@ -232,10 +197,6 @@ function LandingCtrl($scope, $timeout, $location, Restangular, UserService, KeyS $scope.awaitingConfirmation = false; $scope.registering = false; - $scope.getCommentFirstLine = function(commentString) { - return getMarkedDown(getFirstTextLine(commentString)); - }; - $scope.register = function() { $('.form-signup').popover('hide'); $scope.registering = true; @@ -255,18 +216,42 @@ function LandingCtrl($scope, $timeout, $location, Restangular, UserService, KeyS }); }; - $scope.loadMyRepos = function() { - $scope.loadingmyrepos = true; + $scope.canCreateRepo = function(namespace) { + if (!$scope.user) { return false; } - // Load the list of repositories. - var params = { - 'limit': 5, - 'public': false, - 'sort': true - }; + if (namespace == $scope.user.username) { + return true; + } + + if ($scope.user.organizations) { + for (var i = 0; i < $scope.user.organizations.length; ++i) { + var org = $scope.user.organizations[i]; + if (org.name == namespace) { + return org.can_create_repo; + } + } + } - var repositoryFetch = Restangular.all('repository/'); - repositoryFetch.getList(params).then(function(resp) { + return false; + }; + + var loadMyRepos = function(namespace) { + if (!$scope.user || $scope.user.anonymous || !namespace) { + return; + } + + $scope.loadingmyrepos = true; + + // Load the list of repositories. + var params = { + 'limit': 4, + 'public': false, + 'sort': true, + 'namespace': namespace + }; + + var repositoryFetch = Restangular.all('repository/'); + repositoryFetch.getList(params).then(function(resp) { $scope.myrepos = resp.repositories; $scope.loadingmyrepos = false; }); @@ -285,23 +270,8 @@ function RepoCtrl($scope, Restangular, $routeParams, $rootScope, $location, $tim $scope.setTag($location.search().tag, false); }); - $scope.editDescription = function() { - if (!$scope.repo.can_write) { return; } - - if (!$scope.markdownDescriptionEditor) { - var converter = Markdown.getSanitizingConverter(); - var editor = new Markdown.Editor(converter, '-description'); - editor.run(); - $scope.markdownDescriptionEditor = editor; - } - - $('#wmd-input-description')[0].value = $scope.repo.description; - $('#editModal').modal({}); - }; - - $scope.saveDescription = function() { - $('#editModal').modal('hide'); - $scope.repo.description = $('#wmd-input-description')[0].value; + $scope.updateForDescription = function(content) { + $scope.repo.description = content; $scope.repo.put(); }; @@ -309,19 +279,10 @@ function RepoCtrl($scope, Restangular, $routeParams, $rootScope, $location, $tim return Date.parse(dateString); }; - $scope.getCommentFirstLine = function(commentString) { - return getMarkedDown(getFirstTextLine(commentString)); - }; - $scope.getTimeSince = function(createdTime) { return moment($scope.parseDate(createdTime)).fromNow(); }; - $scope.getMarkedDown = function(string) { - if (!string) { return ''; } - return getMarkedDown(string); - }; - var getDefaultTag = function() { if ($scope.repo === undefined) { return undefined; @@ -420,7 +381,7 @@ function RepoCtrl($scope, Restangular, $routeParams, $rootScope, $location, $tim // Create the new tree. $scope.tree = new ImageHistoryTree(namespace, name, resp.images, - $scope.getCommentFirstLine, $scope.getTimeSince); + getFirstTextLine, $scope.getTimeSince); $scope.tree.draw('image-history-container'); @@ -525,39 +486,7 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) { var namespace = $routeParams.namespace; var name = $routeParams.name; - $scope.$on('$viewContentLoaded', function() { - // THIS IS BAD, MOVE THIS TO A DIRECTIVE - $('#userSearch').typeahead({ - name: 'users', - remote: { - url: '/api/users/%QUERY', - filter: function(data) { - var datums = []; - for (var i = 0; i < data.users.length; ++i) { - var user = data.users[i]; - datums.push({ - 'value': user, - 'tokens': [user], - 'username': user - }); - } - return datums; - } - }, - template: function (datum) { - template = '
'; - template += '' - template += '' + datum.username + '' - template += '
' - return template; - }, - }); - - $('#userSearch').on('typeahead:selected', function(e, datum) { - $('#userSearch').typeahead('setQuery', ''); - $scope.addNewPermission(datum.username); - }); - }); + $scope.permissions = {'team': [], 'user': []}; $scope.isDownloadSupported = function() { try { return !!new Blob(); } catch(e){} @@ -578,54 +507,76 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) { saveAs(blob, '.dockercfg'); }; - $scope.addNewPermission = function(username) { + $scope.grantRole = function() { + $('#confirmaddoutsideModal').modal('hide'); + var entity = $scope.currentAddEntity; + $scope.addRole(entity.name, 'read', entity.kind, entity.is_org_member) + $scope.currentAddEntity = null; + }; + + $scope.addNewPermission = function(entity) { // Don't allow duplicates. - if ($scope.permissions[username]) { return; } + if ($scope.permissions[entity.kind][entity.name]) { return; } + + if (entity.is_org_member === false) { + $scope.currentAddEntity = entity; + $('#confirmaddoutsideModal').modal('show'); + return; + } // Need the $scope.apply for both the permission stuff to change and for // the XHR call to be made. $scope.$apply(function() { - $scope.addRole(username, 'read') + $scope.addRole(entity.name, 'read', entity.kind, entity.is_org_member) }); }; - $scope.deleteRole = function(username) { - var permissionDelete = Restangular.one('repository/' + namespace + '/' + name + '/permissions/' + username); + $scope.deleteRole = function(entityName, kind) { + var permissionDelete = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName)); permissionDelete.customDELETE().then(function() { - delete $scope.permissions[username]; - }, function(result) { - if (result.status == 409) { - $('#onlyadminModal').modal({}); + delete $scope.permissions[kind][entityName]; + }, function(resp) { + if (resp.status == 409) { + $scope.changePermError = resp.data || ''; + $('#channgechangepermModal').modal({}); } else { $('#cannotchangeModal').modal({}); } }); }; - $scope.addRole = function(username, role) { + $scope.addRole = function(entityName, role, kind, is_org_member) { var permission = { - 'role': role + 'role': role, + 'is_org_member': is_org_member }; - var permissionPost = Restangular.one('repository/' + namespace + '/' + name + '/permissions/' + username); + var permissionPost = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName)); permissionPost.customPOST(permission).then(function() { - $scope.permissions[username] = permission; - $scope.permissions = $scope.permissions; + $scope.permissions[kind][entityName] = permission; }, function(result) { $('#cannotchangeModal').modal({}); }); }; - $scope.setRole = function(username, role) { - var permission = $scope.permissions[username]; + $scope.roles = [ + { 'id': 'read', 'title': 'Read', 'kind': 'success' }, + { 'id': 'write', 'title': 'Write', 'kind': 'success' }, + { 'id': 'admin', 'title': 'Admin', 'kind': 'primary' } + ]; + + $scope.setRole = function(role, entityName, kind) { + var permission = $scope.permissions[kind][entityName]; var currentRole = permission.role; permission.role = role; - var permissionPut = Restangular.one('repository/' + namespace + '/' + name + '/permissions/' + username); - permissionPut.customPUT(permission).then(function() {}, function(result) { - if (result.status == 409) { - permission.role = currentRole; - $('#onlyadminModal').modal({}); + var permissionPut = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName)); + permissionPut.customPUT(permission).then(function() {}, function(resp) { + $scope.permissions[kind][entityName] = {'role': currentRole}; + $scope.changePermError = null; + if (resp.status == 409 || resp.data) { + $scope.changePermError = resp.data || ''; + $('#channgechangepermModal').modal({}); } else { $('#cannotchangeModal').modal({}); } @@ -707,6 +658,23 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) { $scope.loading = true; + var checkLoading = function() { + $scope.loading = !($scope.permissions['user'] && $scope.permissions['team'] && $scope.repo && $scope.tokens); + }; + + var fetchPermissions = function(kind) { + var permissionsFetch = Restangular.one('repository/' + namespace + '/' + name + '/permissions/' + kind + '/'); + permissionsFetch.get().then(function(resp) { + $rootScope.title = 'Settings - ' + namespace + '/' + name; + $scope.permissions[kind] = resp.permissions; + checkLoading(); + }, function() { + $scope.permissions[kind] = null; + $rootScope.title = 'Unknown Repository'; + $scope.loading = false; + }); + }; + // Fetch the repository information. var repositoryFetch = Restangular.one('repository/' + namespace + '/' + name); repositoryFetch.get().then(function(repo) { @@ -718,23 +686,15 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) { $scope.loading = false; }); - // Fetch the permissions. - var permissionsFetch = Restangular.one('repository/' + namespace + '/' + name + '/permissions'); - permissionsFetch.get().then(function(resp) { - $rootScope.title = 'Settings - ' + namespace + '/' + name; - $scope.permissions = resp.permissions; - $scope.loading = !($scope.permissions && $scope.repo && $scope.tokens); - }, function() { - $scope.permissions = null; - $rootScope.title = 'Unknown Repository'; - $scope.loading = false; - }); + // Fetch the user and team permissions. + fetchPermissions('user'); + fetchPermissions('team'); // Fetch the tokens. var tokensFetch = Restangular.one('repository/' + namespace + '/' + name + '/tokens/'); tokensFetch.get().then(function(resp) { $scope.tokens = resp.tokens; - $scope.loading = !($scope.permissions && $scope.repo && $scope.tokens); + checkLoading(); }, function() { $scope.tokens = null; $scope.loading = false; @@ -742,86 +702,71 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) { } -function UserAdminCtrl($scope, $timeout, Restangular, PlanService, UserService, KeyService, $routeParams) { - $scope.plans = PlanService.planList(); - +function UserAdminCtrl($scope, $timeout, $location, Restangular, PlanService, UserService, KeyService, $routeParams) { $scope.$watch(function () { return UserService.currentUser(); }, function (currentUser) { $scope.askForPassword = currentUser.askForPassword; + if (!currentUser.anonymous) { + $scope.user = currentUser; + } + $scope.loading = false; }, true); - var subscribedToPlan = function(sub) { - $scope.subscription = sub; - $scope.subscribedPlan = PlanService.getPlan(sub.plan); - $scope.planUsagePercent = sub.usedPrivateRepos * 100 / $scope.subscribedPlan.privateRepos; - - if (sub.usedPrivateRepos > $scope.subscribedPlan.privateRepos) { - $scope.errorMessage = 'You are using more private repositories than your plan allows, please upgrate your subscription to avoid disruptions in your service.'; - } else { - $scope.errorMessage = null; - } - - $scope.planLoading = false; - $scope.planChanging = false; - - mixpanel.people.set({ - 'plan': sub.plan - }); + $scope.readyForPlan = function() { + // Show the subscribe dialog if a plan was requested. + return $routeParams['plan']; }; - $scope.planLoading = true; - var getSubscription = Restangular.one('user/plan'); - getSubscription.get().then(subscribedToPlan, function() { - // User has no subscription - $scope.planLoading = false; - }); - - $scope.planChanging = false; - $scope.subscribe = function(planId) { - PlanService.showSubscribeDialog($scope, planId, function() { - // Subscribing. - $scope.planChanging = true; - }, function(plan) { - // Subscribed. - subscribedToPlan(plan); - }, function() { - // Failure. - $scope.errorMessage = 'Unable to subscribe.'; - $scope.planChanging = false; - }); - }; - - $scope.changeSubscription = function(planId) { - $scope.planChanging = true; - $scope.errorMessage = undefined; - - var subscriptionDetails = { - plan: planId, - }; - - var changeSubscriptionRequest = Restangular.one('user/plan'); - changeSubscriptionRequest.customPUT(subscriptionDetails).then(subscribedToPlan, function() { - // Failure - $scope.errorMessage = 'Unable to change subscription.'; - $scope.planChanging = false; - }); - }; - - $scope.cancelSubscription = function() { - $scope.changeSubscription('free'); - }; - - // Show the subscribe dialog if a plan was requested. - var requested = $routeParams['plan'] - if (requested !== undefined && requested !== 'free') { - if (PlanService.getPlan(requested) !== undefined) { - $scope.subscribe(requested); - } + if ($routeParams['migrate']) { + $('#migrateTab').tab('show') } + $scope.loading = true; $scope.updatingUser = false; $scope.changePasswordSuccess = false; + $scope.convertStep = 0; + $scope.org = {}; + $('.form-change-pw').popover(); + $scope.showConvertForm = function() { + PlanService.getMatchingBusinessPlan(function(plan) { + $scope.org.plan = plan; + }); + + PlanService.getPlans(function(plans) { + $scope.orgPlans = plans.business; + }); + + $scope.convertStep = 1; + }; + + $scope.convertToOrg = function() { + $('#reallyconvertModal').modal({}); + }; + + $scope.reallyConvert = function() { + $scope.loading = true; + + var data = { + 'adminUser': $scope.org.adminUser, + 'adminPassword': $scope.org.adminPassword, + 'plan': $scope.org.plan.stripeId + }; + + var convertAccount = Restangular.one('user/convert'); + convertAccount.customPOST(data).then(function(resp) { + UserService.load(); + $location.path('/'); + }, function(resp) { + $scope.loading = false; + if (resp.data.reason == 'invaliduser') { + $('#invalidadminModal').modal({}); + } else { + $('#cannotconvertModal').modal({}); + } + }); + }; + $scope.changePassword = function() { $('.form-change-pw').popover('hide'); $scope.updatingUser = true; @@ -836,6 +781,7 @@ function UserAdminCtrl($scope, $timeout, Restangular, PlanService, UserService, $scope.user.repeatPassword = ''; $scope.changePasswordForm.$setPristine(); + // Reload the user. UserService.load(); }, function(result) { $scope.updatingUser = false; @@ -855,11 +801,6 @@ function ImageViewCtrl($scope, $routeParams, $rootScope, Restangular) { $('#copyClipboard').clipboardCopy(); - $scope.getMarkedDown = function(string) { - if (!string) { return ''; } - return getMarkedDown(string); - }; - $scope.parseDate = function(dateString) { return Date.parse(dateString); }; @@ -953,7 +894,7 @@ function V1Ctrl($scope, $location, UserService) { }, true); } -function NewRepoCtrl($scope, $location, $http, UserService, Restangular, PlanService) { +function NewRepoCtrl($scope, $location, $http, $timeout, UserService, Restangular, PlanService) { $scope.$watch( function () { return UserService.currentUser(); }, function (currentUser) { $scope.user = currentUser; }, true); @@ -1039,36 +980,26 @@ function NewRepoCtrl($scope, $location, $http, UserService, Restangular, PlanSer var subscribedToPlan = function(sub) { $scope.planChanging = false; $scope.subscription = sub; - $scope.subscribedPlan = PlanService.getPlan(sub.plan); - $scope.planRequired = null; - if ($scope.subscription.usedPrivateRepos >= $scope.subscribedPlan.privateRepos) { - $scope.planRequired = PlanService.getMinimumPlan($scope.subscription.usedPrivateRepos); - } - }; - $scope.editDescription = function() { - if (!$scope.markdownDescriptionEditor) { - var converter = Markdown.getSanitizingConverter(); - var editor = new Markdown.Editor(converter, '-description'); - editor.run(); - $scope.markdownDescriptionEditor = editor; - } + PlanService.getPlan(sub.plan, function(subscribedPlan) { + $scope.subscribedPlan = subscribedPlan; + $scope.planRequired = null; - $('#wmd-input-description')[0].value = $scope.repo.description; - $('#editModal').modal({}); - }; - - $scope.getMarkedDown = function(string) { - if (!string) { return ''; } - return getMarkedDown(string); - }; - - $scope.saveDescription = function() { - $('#editModal').modal('hide'); - $scope.repo.description = $('#wmd-input-description')[0].value; + // Check to see if the current plan allows for an additional private repository to + // be created. + var privateAllowed = $scope.subscription.usedPrivateRepos < $scope.subscribedPlan.privateRepos; + if (!privateAllowed) { + // If not, find the minimum repository that does. + PlanService.getMinimumPlan($scope.subscription.usedPrivateRepos + 1, !$scope.isUserNamespace, function(minimum) { + $scope.planRequired = minimum; + }); + } + }); }; $scope.createNewRepo = function() { + $('#repoName').popover('hide'); + var uploader = $('#file-drop')[0]; if ($scope.repo.initialize && uploader.files.length < 1) { $('#missingfileModal').modal(); @@ -1078,6 +1009,7 @@ function NewRepoCtrl($scope, $location, $http, UserService, Restangular, PlanSer $scope.creating = true; var repo = $scope.repo; var data = { + 'namespace': repo.namespace, 'repository': repo.name, 'visibility': repo.is_public == '1' ? 'public' : 'private', 'description': repo.description @@ -1096,18 +1028,22 @@ function NewRepoCtrl($scope, $location, $http, UserService, Restangular, PlanSer // Otherwise, redirect to the repo page. $location.path('/repository/' + created.namespace + '/' + created.name); - }, function() { - $('#cannotcreateModal').modal(); + }, function(result) { $scope.creating = false; + $scope.createError = result.data; + $timeout(function() { + $('#repoName').popover('show'); + }); }); }; $scope.upgradePlan = function() { - PlanService.showSubscribeDialog($scope, $scope.planRequired.stripeId, function() { + PlanService.changePlan($scope, null, $scope.planRequired.stripeId, null, function() { // Subscribing. $scope.planChanging = true; }, function(plan) { // Subscribed. + UserService.resetCurrentSubscription(); subscribedToPlan(plan); }, function() { // Failure. @@ -1115,14 +1051,344 @@ function NewRepoCtrl($scope, $location, $http, UserService, Restangular, PlanSer $scope.planChanging = false; }); }; + + // Watch the namespace on the repo. If it changes, we update the plan and the public/private + // accordingly. + $scope.isUserNamespace = true; + $scope.$watch('repo.namespace', function(namespace) { + // Note: Can initially be undefined. + if (!namespace) { return; } + + var isUserNamespace = (namespace == $scope.user.username); - $scope.plans = PlanService.planList(); + $scope.planRequired = null; + $scope.isUserNamespace = isUserNamespace; - // Load the user's subscription information in case they want to create a private - // repository. - var getSubscription = Restangular.one('user/plan'); - getSubscription.get().then(subscribedToPlan, function() { - // User has no subscription - $scope.planRequired = PlanService.getMinimumPlan(1); + if (isUserNamespace) { + // Load the user's subscription information in case they want to create a private + // repository. + UserService.getCurrentSubscription(subscribedToPlan, function() { + PlanService.getMinimumPlan(1, false, function(minimum) { $scope.planRequired = minimum; }); + }); + } else { + $scope.planRequired = null; + + var checkPrivateAllowed = Restangular.one('organization/' + namespace + '/private'); + checkPrivateAllowed.get().then(function(resp) { + $scope.planRequired = resp.privateAllowed ? null : {}; + }, function() { + $scope.planRequired = {}; + }); + + // Auto-set to private repo. + $scope.repo.is_public = '0'; + } }); +} + +function OrgViewCtrl($rootScope, $scope, Restangular, $routeParams) { + $('.info-icon').popover({ + 'trigger': 'hover', + 'html': true + }); + + $rootScope.title = 'Loading...'; + + var orgname = $routeParams.orgname; + + var loadOrganization = function() { + var getOrganization = Restangular.one(getRestUrl('organization', orgname)); + getOrganization.get().then(function(resp) { + $scope.organization = resp; + $scope.loading = false; + + $rootScope.title = orgname; + }, function() { + $scope.loading = false; + }); + }; + + $scope.teamRoles = [ + { 'id': 'member', 'title': 'Member', 'kind': 'default' }, + { 'id': 'creator', 'title': 'Creator', 'kind': 'success' }, + { 'id': 'admin', 'title': 'Admin', 'kind': 'primary' } + ]; + + $scope.setRole = function(role, teamname) { + var previousRole = $scope.organization.teams[teamname].role; + $scope.organization.teams[teamname].role = role; + + var updateTeam = Restangular.one(getRestUrl('organization', orgname, 'team', teamname)); + var data = $scope.organization.teams[teamname]; + + updateTeam.customPUT(data).then(function(resp) { + }, function(resp) { + $scope.organization.teams[teamname].role = previousRole; + $scope.roleError = resp.data || ''; + $('#cannotChangeTeamModal').modal({}); + }); + }; + + $scope.createTeamShown = function() { + setTimeout(function() { + $('#create-team-box').focus(); + }, 10); + }; + + $scope.createTeam = function() { + var box = $('#create-team-box'); + if (box.hasClass('ng-invalid')) { return; } + + var teamname = box[0].value.toLowerCase(); + if (!teamname) { + return; + } + + if ($scope.organization.teams[teamname]) { + $('#team-' + teamname).removeClass('highlight'); + setTimeout(function() { + $('#team-' + teamname).addClass('highlight'); + }, 10); + return; + } + + var createTeam = Restangular.one(getRestUrl('organization', orgname, 'team', teamname)); + var data = { + 'name': teamname, + 'role': 'member' + }; + createTeam.customPOST(data).then(function(resp) { + $scope.organization.teams[teamname] = resp; + }, function() { + $('#cannotChangeTeamModal').modal({}); + }); + }; + + $scope.askDeleteTeam = function(teamname) { + $scope.currentDeleteTeam = teamname; + $('#confirmdeleteModal').modal({}); + }; + + $scope.deleteTeam = function() { + $('#confirmdeleteModal').modal('hide'); + if (!$scope.currentDeleteTeam) { return; } + + var teamname = $scope.currentDeleteTeam; + var deleteAction = Restangular.one(getRestUrl('organization', orgname, 'team', teamname)); + deleteAction.customDELETE().then(function() { + delete $scope.organization.teams[teamname]; + $scope.currentDeleteTeam = null; + }, function() { + $('#cannotchangeModal').modal({}); + $scope.currentDeleteTeam = null; + }); + }; + + loadOrganization(); +} + +function OrgAdminCtrl($rootScope, $scope, Restangular, $routeParams, UserService, PlanService) { + // Load the list of plans. + PlanService.getPlans(function(plans) { + $scope.plans = plans.business; + }); + + var orgname = $routeParams.orgname; + + $scope.orgname = orgname; + $scope.membersLoading = true; + $scope.membersFound = null; + + $scope.loadMembers = function() { + if ($scope.membersFound) { return; } + $scope.membersLoading = true; + + var getMembers = Restangular.one(getRestUrl('organization', orgname, 'members')); + getMembers.get().then(function(resp) { + var membersArray = []; + for (var key in resp.members) { + if (resp.members.hasOwnProperty(key)) { + membersArray.push(resp.members[key]); + } + } + + $scope.membersFound = membersArray; + $scope.membersLoading = false; + }); + }; + + var loadOrganization = function() { + var getOrganization = Restangular.one(getRestUrl('organization', orgname)); + getOrganization.get().then(function(resp) { + if (resp && resp.is_admin) { + $scope.organization = resp; + $rootScope.title = orgname + ' (Admin)'; + } + + $scope.loading = false; + }, function() { + $scope.loading = false; + }); + }; + + loadOrganization(); +} + +function TeamViewCtrl($rootScope, $scope, Restangular, $routeParams) { + $('.info-icon').popover({ + 'trigger': 'hover', + 'html': true + }); + + var orgname = $routeParams.orgname; + var teamname = $routeParams.teamname; + + $rootScope.title = 'Loading...'; + $scope.loading = true; + $scope.teamname = teamname; + + $scope.addNewMember = function(member) { + if ($scope.members[member.name]) { return; } + + $scope.$apply(function() { + var addMember = Restangular.one(getRestUrl('organization', orgname, 'team', teamname, 'members', member.name)); + addMember.customPOST().then(function(resp) { + $scope.members[member.name] = resp; + }, function() { + $('#cannotChangeMembersModal').modal({}); + }); + }); + }; + + $scope.removeMember = function(username) { + var removeMember = Restangular.one(getRestUrl('organization', orgname, 'team', teamname, 'members', username)); + removeMember.customDELETE().then(function(resp) { + delete $scope.members[username]; + }, function() { + $('#cannotChangeMembersModal').modal({}); + }); + }; + + $scope.updateForDescription = function(content) { + $scope.organization.teams[teamname].description = content; + + var updateTeam = Restangular.one(getRestUrl('organization', orgname, 'team', teamname)); + var data = $scope.organization.teams[teamname]; + updateTeam.customPUT(data).then(function(resp) { + }, function() { + $('#cannotChangeTeamModal').modal({}); + }); + }; + + var loadOrganization = function() { + var getOrganization = Restangular.one(getRestUrl('organization', orgname)) + getOrganization.get().then(function(resp) { + $scope.organization = resp; + $scope.team = $scope.organization.teams[teamname]; + $scope.loading = !$scope.organization || !$scope.members; + }, function() { + $scope.organization = null; + $scope.members = null; + $scope.loading = false; + }); + }; + + var loadMembers = function() { + var getMembers = Restangular.one(getRestUrl('organization', orgname, 'team', teamname, 'members')); + getMembers.get().then(function(resp) { + $scope.members = resp.members; + $scope.canEditMembers = resp.can_edit; + $scope.loading = !$scope.organization || !$scope.members; + $rootScope.title = teamname + ' (' + orgname + ')'; + }, function() { + $scope.organization = null; + $scope.members = null; + $scope.loading = false; + }); + }; + + loadOrganization(); + loadMembers(); +} + +function OrgsCtrl($scope, UserService) { + $scope.loading = true; + + $scope.$watch( function () { return UserService.currentUser(); }, function (currentUser) { + $scope.user = currentUser; + $scope.loading = false; + }, true); + + browserchrome.update(); +} + +function NewOrgCtrl($scope, $routeParams, $timeout, $location, UserService, PlanService, Restangular) { + $scope.loading = true; + + $scope.$watch( function () { return UserService.currentUser(); }, function (currentUser) { + $scope.user = currentUser; + $scope.loading = false; + }, true); + + requested = $routeParams['plan']; + + // Load the list of plans. + PlanService.getPlans(function(plans) { + $scope.plans = plans.business; + $scope.currentPlan = null; + if (requested) { + PlanService.getPlan(requested, function(plan) { + $scope.currentPlan = plan; + }); + } + }); + + $scope.setPlan = function(plan) { + $scope.currentPlan = plan; + }; + + $scope.createNewOrg = function() { + $('#orgName').popover('hide'); + + $scope.creating = true; + var org = $scope.org; + var data = { + 'name': org.name, + 'email': org.email + }; + + var createPost = Restangular.one('organization/'); + createPost.customPOST(data).then(function(created) { + $scope.creating = false; + $scope.created = created; + + // Reset the organizations list. + UserService.load(); + + // If the selected plan is free, simply move to the org page. + if ($scope.currentPlan.price == 0) { + $location.path('/organization/' + org.name + '/'); + return; + } + + // Otherwise, show the subscribe for the plan. + PlanService.changePlan($scope, org.name, $scope.currentPlan.stripeId, false, function() { + // Started. + $scope.creating = true; + }, function(sub) { + // Success. + $location.path('/organization/' + org.name + '/'); + }, function() { + // Failure. + $location.path('/organization/' + org.name + '/'); + }); + + }, function(result) { + $scope.creating = false; + $scope.createError = result.data.message || result.data; + $timeout(function() { + $('#orgName').popover('show'); + }); + }); + }; } \ No newline at end of file diff --git a/static/js/graphing.js b/static/js/graphing.js index 5581dc3ce..ced8a99d5 100644 --- a/static/js/graphing.js +++ b/static/js/graphing.js @@ -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 += '' + d.encountered[i].image.id.substr(0, 12) + ''; @@ -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_(); }; \ No newline at end of file diff --git a/static/lib/angular-cookies.min.js b/static/lib/angular-cookies.min.js new file mode 100644 index 000000000..6807b369a --- /dev/null +++ b/static/lib/angular-cookies.min.js @@ -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); \ No newline at end of file diff --git a/static/lib/angular-strap.min.js b/static/lib/angular-strap.min.js index 7bf8788fb..08b9322ad 100644 --- a/static/lib/angular-strap.min.js +++ b/static/lib/angular-strap.min.js @@ -5,4 +5,4 @@ * @author Olivier Louvignes * @license MIT License, http://www.opensource.org/licenses/MIT */ -angular.module("$strap.config",[]).value("$strapConfig",{}),angular.module("$strap.filters",["$strap.config"]),angular.module("$strap.directives",["$strap.config"]),angular.module("$strap",["$strap.filters","$strap.directives","$strap.config"]),angular.module("$strap.directives").directive("bsAlert",["$parse","$timeout","$compile",function(t,e,n){return{restrict:"A",link:function(a,i,o){var r=t(o.bsAlert),s=(r.assign,r(a)),l=function(t){e(function(){i.alert("close")},1*t)};o.bsAlert?a.$watch(o.bsAlert,function(t,e){s=t,i.html((t.title?""+t.title+" ":"")+t.content||""),t.closed&&i.hide(),n(i.contents())(a),(t.type||e.type)&&(e.type&&i.removeClass("alert-"+e.type),t.type&&i.addClass("alert-"+t.type)),angular.isDefined(t.closeAfter)?l(t.closeAfter):o.closeAfter&&l(o.closeAfter),(angular.isUndefined(o.closeButton)||"0"!==o.closeButton&&"false"!==o.closeButton)&&i.prepend('')},!0):((angular.isUndefined(o.closeButton)||"0"!==o.closeButton&&"false"!==o.closeButton)&&i.prepend(''),o.closeAfter&&l(o.closeAfter)),i.addClass("alert").alert(),i.hasClass("fade")&&(i.removeClass("in"),setTimeout(function(){i.addClass("in")}));var u=o.ngRepeat&&o.ngRepeat.split(" in ").pop();i.on("close",function(t){var e;u?(t.preventDefault(),i.removeClass("in"),e=function(){i.trigger("closed"),a.$parent&&a.$parent.$apply(function(){for(var t=u.split("."),e=a.$parent,n=0;t.length>n;++n)e&&(e=e[t[n]]);e&&e.splice(a.$index,1)})},$.support.transition&&i.hasClass("fade")?i.on($.support.transition.end,e):e()):s&&(t.preventDefault(),i.removeClass("in"),e=function(){i.trigger("closed"),a.$apply(function(){s.closed=!0})},$.support.transition&&i.hasClass("fade")?i.on($.support.transition.end,e):e())})}}}]),angular.module("$strap.directives").directive("bsButton",["$parse","$timeout",function(t){return{restrict:"A",require:"?ngModel",link:function(e,n,a,i){if(i){n.parent('[data-toggle="buttons-checkbox"], [data-toggle="buttons-radio"]').length||n.attr("data-toggle","button");var o=!!e.$eval(a.ngModel);o&&n.addClass("active"),e.$watch(a.ngModel,function(t,e){var a=!!t,i=!!e;a!==i?$.fn.button.Constructor.prototype.toggle.call(r):a&&!o&&n.addClass("active")})}n.hasClass("btn")||n.on("click.button.data-api",function(){n.button("toggle")}),n.button();var r=n.data("button");r.toggle=function(){if(!i)return $.fn.button.Constructor.prototype.toggle.call(this);var a=n.parent('[data-toggle="buttons-radio"]');a.length?(n.siblings("[ng-model]").each(function(n,a){t($(a).attr("ng-model")).assign(e,!1)}),e.$digest(),i.$modelValue||(i.$setViewValue(!i.$modelValue),e.$digest())):e.$apply(function(){i.$setViewValue(!i.$modelValue)})}}}}]).directive("bsButtonsCheckbox",["$parse",function(){return{restrict:"A",require:"?ngModel",compile:function(t){t.attr("data-toggle","buttons-checkbox").find("a, button").each(function(t,e){$(e).attr("bs-button","")})}}}]).directive("bsButtonsRadio",["$timeout",function(t){return{restrict:"A",require:"?ngModel",compile:function(e,n){return e.attr("data-toggle","buttons-radio"),n.ngModel||e.find("a, button").each(function(t,e){$(e).attr("bs-button","")}),function(e,n,a,i){i&&(t(function(){n.find("[value]").button().filter('[value="'+i.$viewValue+'"]').addClass("active")}),n.on("click.button.data-api",function(t){e.$apply(function(){i.$setViewValue($(t.target).closest("button").attr("value"))})}),e.$watch(a.ngModel,function(t,i){if(t!==i){var o=n.find('[value="'+e.$eval(a.ngModel)+'"]');o.length&&o.button("toggle")}}))}}}}]),angular.module("$strap.directives").directive("bsButtonSelect",["$parse","$timeout",function(t){return{restrict:"A",require:"?ngModel",link:function(e,n,a,i){var o=t(a.bsButtonSelect);o.assign,i&&(n.text(e.$eval(a.ngModel)),e.$watch(a.ngModel,function(t){n.text(t)}));var r,s,l,u;n.bind("click",function(){r=o(e),s=i?e.$eval(a.ngModel):n.text(),l=r.indexOf(s),u=l>r.length-2?r[0]:r[l+1],e.$apply(function(){n.text(u),i&&i.$setViewValue(u)})})}}}]),angular.module("$strap.directives").directive("bsDatepicker",["$timeout","$strapConfig",function(t,e){var n=/(iP(a|o)d|iPhone)/g.test(navigator.userAgent),a=function a(t){return t=t||"en",{"/":"[\\/]","-":"[-]",".":"[.]"," ":"[\\s]",dd:"(?:(?:[0-2]?[0-9]{1})|(?:[3][01]{1}))",d:"(?:(?:[0-2]?[0-9]{1})|(?:[3][01]{1}))",mm:"(?:[0]?[1-9]|[1][012])",m:"(?:[0]?[1-9]|[1][012])",DD:"(?:"+$.fn.datepicker.dates[t].days.join("|")+")",D:"(?:"+$.fn.datepicker.dates[t].daysShort.join("|")+")",MM:"(?:"+$.fn.datepicker.dates[t].months.join("|")+")",M:"(?:"+$.fn.datepicker.dates[t].monthsShort.join("|")+")",yyyy:"(?:(?:[1]{1}[0-9]{1}[0-9]{1}[0-9]{1})|(?:[2]{1}[0-9]{3}))(?![[0-9]])",yy:"(?:(?:[0-9]{1}[0-9]{1}))(?![[0-9]])"}},i=function i(t,e){var n,i=t,o=a(e);return n=0,angular.forEach(o,function(t,e){i=i.split(e).join("${"+n+"}"),n++}),n=0,angular.forEach(o,function(t){i=i.split("${"+n+"}").join(t),n++}),RegExp("^"+i+"$",["i"])};return{restrict:"A",require:"?ngModel",link:function(t,a,o,r){var s=angular.extend({autoclose:!0},e.datepicker||{}),l=o.dateType||s.type||"date";angular.forEach(["format","weekStart","calendarWeeks","startDate","endDate","daysOfWeekDisabled","autoclose","startView","minViewMode","todayBtn","todayHighlight","keyboardNavigation","language","forceParse"],function(t){angular.isDefined(o[t])&&(s[t]=o[t])});var u=s.language||"en",c=o.dateFormat||s.format||$.fn.datepicker.dates[u]&&$.fn.datepicker.dates[u].format||"mm/dd/yyyy",d=n?"yyyy-mm-dd":c,p=i(d,u);r&&(r.$formatters.unshift(function(t){return"date"===l&&angular.isString(t)&&t?$.fn.datepicker.DPGlobal.parseDate(t,$.fn.datepicker.DPGlobal.parseFormat(c),u):t}),r.$parsers.unshift(function(t){return t?"date"===l&&angular.isDate(t)?(r.$setValidity("date",!0),t):angular.isString(t)&&p.test(t)?(r.$setValidity("date",!0),n?new Date(t):"string"===l?t:$.fn.datepicker.DPGlobal.parseDate(t,$.fn.datepicker.DPGlobal.parseFormat(d),u)):(r.$setValidity("date",!1),void 0):(r.$setValidity("date",!0),null)}),r.$render=function(){if(n){var t=r.$viewValue?$.fn.datepicker.DPGlobal.formatDate(r.$viewValue,$.fn.datepicker.DPGlobal.parseFormat(d),u):"";return a.val(t),t}return r.$viewValue||a.val(""),a.datepicker("update",r.$viewValue)}),n?a.prop("type","date").css("-webkit-appearance","textfield"):(r&&a.on("changeDate",function(e){t.$apply(function(){r.$setViewValue("string"===l?a.val():e.date)})}),a.datepicker(angular.extend(s,{format:d,language:u})),t.$on("$destroy",function(){var t=a.data("datepicker");t&&(t.picker.remove(),a.data("datepicker",null))}),o.$observe("startDate",function(t){a.datepicker("setStartDate",t)}),o.$observe("endDate",function(t){a.datepicker("setEndDate",t)}));var f=a.siblings('[data-toggle="datepicker"]');f.length&&f.on("click",function(){a.prop("disabled")||a.trigger("focus")})}}}]),angular.module("$strap.directives").directive("bsDropdown",["$parse","$compile","$timeout",function(t,e,n){var a=function(t,e){return e||(e=['"]),angular.forEach(t,function(t,n){if(t.divider)return e.splice(n+1,0,'
  • ');var i=""+'"+(t.text||"")+"";t.submenu&&t.submenu.length&&(i+=a(t.submenu).join("\n")),i+="",e.splice(n+1,0,i)}),e};return{restrict:"EA",scope:!0,link:function(i,o,r){var s=t(r.bsDropdown),l=s(i);n(function(){!angular.isArray(l);var t=angular.element(a(l).join(""));t.insertAfter(o),e(o.next("ul.dropdown-menu"))(i)}),o.addClass("dropdown-toggle").attr("data-toggle","dropdown")}}}]),angular.module("$strap.directives").factory("$modal",["$rootScope","$compile","$http","$timeout","$q","$templateCache","$strapConfig",function(t,e,n,a,i,o,r){var s=function s(s){function l(s){var l=angular.extend({show:!0},r.modal,s),u=l.scope?l.scope:t.$new(),c=l.template;return i.when(o.get(c)||n.get(c,{cache:!0}).then(function(t){return t.data})).then(function(t){var n=c.replace(".html","").replace(/[\/|\.|:]/g,"-")+"-"+u.$id,i=$('').attr("id",n).addClass("fade").html(t);return l.modalClass&&i.addClass(l.modalClass),$("body").append(i),a(function(){e(i)(u)}),u.$modal=function(t){i.modal(t)},angular.forEach(["show","hide"],function(t){u[t]=function(){i.modal(t)}}),u.dismiss=u.hide,angular.forEach(["show","shown","hide","hidden"],function(t){i.on(t,function(e){u.$emit("modal-"+t,e)})}),i.on("shown",function(){$("input[autofocus], textarea[autofocus]",i).first().trigger("focus")}),i.on("hidden",function(){l.persist||u.$destroy()}),u.$on("$destroy",function(){i.remove()}),i.modal(l),i})}return new l(s)};return s}]).directive("bsModal",["$q","$modal",function(t,e){return{restrict:"A",scope:!0,link:function(n,a,i){var o={template:n.$eval(i.bsModal),persist:!0,show:!1,scope:n};angular.forEach(["modalClass","backdrop","keyboard"],function(t){angular.isDefined(i[t])&&(o[t]=i[t])}),t.when(e(o)).then(function(t){a.attr("data-target","#"+t.attr("id")).attr("data-toggle","modal")})}}}]),angular.module("$strap.directives").directive("bsNavbar",["$location",function(t){return{restrict:"A",link:function(e,n){e.$watch(function(){return t.path()},function(t){$("li[data-match-route]",n).each(function(e,n){var a=angular.element(n),i=a.attr("data-match-route"),o=RegExp("^"+i+"$",["i"]);o.test(t)?a.addClass("active").find(".collapse.in").collapse("hide"):a.removeClass("active")})})}}}]),angular.module("$strap.directives").directive("bsPopover",["$parse","$compile","$http","$timeout","$q","$templateCache",function(t,e,n,a,i,o){return $("body").on("keyup",function(t){27===t.keyCode&&$(".popover.in").each(function(){$(this).popover("hide")})}),{restrict:"A",scope:!0,link:function(r,s,l){var u=t(l.bsPopover),c=(u.assign,u(r)),d={};angular.isObject(c)&&(d=c),i.when(d.content||o.get(c)||n.get(c,{cache:!0})).then(function(t){angular.isObject(t)&&(t=t.data),l.unique&&s.on("show",function(){$(".popover.in").each(function(){var t=$(this),e=t.data("bs.popover");e&&!e.$element.is(s)&&t.popover("hide")})}),l.hide&&r.$watch(l.hide,function(t,e){t?n.hide():t!==e&&n.show()}),l.show&&r.$watch(l.show,function(t,e){t?a(function(){n.show()}):t!==e&&n.hide()}),s.popover(angular.extend({},d,{content:t,html:!0}));var n=s.data("bs.popover");n.hasContent=function(){return this.getTitle()||t},n.getPosition=function(){var t=$.fn.popover.Constructor.prototype.getPosition.apply(this,arguments);return e(this.$tip)(r),r.$digest(),this.$tip.data("bs.popover",this),t},r.$popover=function(t){n(t)},angular.forEach(["show","hide"],function(t){r[t]=function(){n[t]()}}),r.dismiss=r.hide,angular.forEach(["show","shown","hide","hidden"],function(t){s.on(t,function(e){r.$emit("popover-"+t,e)})})})}}}]),angular.module("$strap.directives").directive("bsSelect",["$timeout",function(t){return{restrict:"A",require:"?ngModel",link:function(e,n,a,i){var o=e.$eval(a.bsSelect)||{};t(function(){n.selectpicker(o),n.next().removeClass("ng-scope")}),i&&e.$watch(a.ngModel,function(t,e){angular.equals(t,e)||n.selectpicker("refresh")})}}}]),angular.module("$strap.directives").directive("bsTabs",["$parse","$compile","$timeout",function(t,e,n){var a='
    ';return{restrict:"A",require:"?ngModel",priority:0,scope:!0,template:a,replace:!0,transclude:!0,compile:function(){return function(e,a,i,o){var r=t(i.bsTabs);r.assign,r(e),e.panes=[];var s,l,u,c=a.find("ul.nav-tabs"),d=a.find("div.tab-content"),p=0;n(function(){d.find("[data-title], [data-tab]").each(function(t){var n=angular.element(this);s="tab-"+e.$id+"-"+t,l=n.data("title")||n.data("tab"),u=!u&&n.hasClass("active"),n.attr("id",s).addClass("tab-pane"),i.fade&&n.addClass("fade"),e.panes.push({id:s,title:l,content:this.innerHTML,active:u})}),e.panes.length&&!u&&(d.find(".tab-pane:first-child").addClass("active"+(i.fade?" in":"")),e.panes[0].active=!0)}),o&&(a.on("show",function(t){var n=$(t.target);e.$apply(function(){o.$setViewValue(n.data("index"))})}),e.$watch(i.ngModel,function(t){angular.isUndefined(t)||(p=t,setTimeout(function(){var e=$(c[0].querySelectorAll("li")[1*t]);e.hasClass("active")||e.children("a").tab("show")}))}))}}}}]),angular.module("$strap.directives").directive("bsTimepicker",["$timeout","$strapConfig",function(t,e){var n="((?:(?:[0-1][0-9])|(?:[2][0-3])|(?:[0-9])):(?:[0-5][0-9])(?::[0-5][0-9])?(?:\\s?(?:am|AM|pm|PM))?)";return{restrict:"A",require:"?ngModel",link:function(a,i,o,r){if(r){i.on("changeTime.timepicker",function(){t(function(){r.$setViewValue(i.val())})});var s=RegExp("^"+n+"$",["i"]);r.$parsers.unshift(function(t){return!t||s.test(t)?(r.$setValidity("time",!0),t):(r.$setValidity("time",!1),void 0)})}i.attr("data-toggle","timepicker"),i.parent().addClass("bootstrap-timepicker"),i.timepicker(e.timepicker||{});var l=i.data("timepicker"),u=i.siblings('[data-toggle="timepicker"]');u.length&&u.on("click",$.proxy(l.showWidget,l))}}}]),angular.module("$strap.directives").directive("bsTooltip",["$parse","$compile",function(t){return{restrict:"A",scope:!0,link:function(e,n,a){var i=t(a.bsTooltip),o=(i.assign,i(e));e.$watch(a.bsTooltip,function(t,e){t!==e&&(o=t)}),a.unique&&n.on("show",function(){$(".tooltip.in").each(function(){var t=$(this),e=t.data("tooltip");e&&!e.$element.is(n)&&t.tooltip("hide")})}),n.tooltip({title:function(){return angular.isFunction(o)?o.apply(null,arguments):o},html:!0});var r=n.data("tooltip");r.show=function(){var t=$.fn.tooltip.Constructor.prototype.show.apply(this,arguments);return this.tip().data("tooltip",this),t},e._tooltip=function(t){n.tooltip(t)},e.hide=function(){n.tooltip("hide")},e.show=function(){n.tooltip("show")},e.dismiss=e.hide}}}]),angular.module("$strap.directives").directive("bsTypeahead",["$parse",function(t){return{restrict:"A",require:"?ngModel",link:function(e,n,a,i){var o=t(a.bsTypeahead),r=(o.assign,o(e));e.$watch(a.bsTypeahead,function(t,e){t!==e&&(r=t)}),n.attr("data-provide","typeahead"),n.typeahead({source:function(){return angular.isFunction(r)?r.apply(null,arguments):r},minLength:a.minLength||1,items:a.items,updater:function(t){return i&&e.$apply(function(){i.$setViewValue(t)}),e.$emit("typeahead-updated",t),t}});var s=n.data("typeahead");s.lookup=function(){var t;return this.query=this.$element.val()||"",this.query.length"+t.title+" ":"")+t.content||""),t.closed&&i.hide(),n(i.contents())(a),(t.type||e.type)&&(e.type&&i.removeClass("alert-"+e.type),t.type&&i.addClass("alert-"+t.type)),angular.isDefined(t.closeAfter)?l(t.closeAfter):o.closeAfter&&l(o.closeAfter),(angular.isUndefined(o.closeButton)||"0"!==o.closeButton&&"false"!==o.closeButton)&&i.prepend('')},!0):((angular.isUndefined(o.closeButton)||"0"!==o.closeButton&&"false"!==o.closeButton)&&i.prepend(''),o.closeAfter&&l(o.closeAfter)),i.addClass("alert").alert(),i.hasClass("fade")&&(i.removeClass("in"),setTimeout(function(){i.addClass("in")}));var u=o.ngRepeat&&o.ngRepeat.split(" in ").pop();i.on("close",function(t){var e;u?(t.preventDefault(),i.removeClass("in"),e=function(){i.trigger("closed"),a.$parent&&a.$parent.$apply(function(){for(var t=u.split("."),e=a.$parent,n=0;t.length>n;++n)e&&(e=e[t[n]]);e&&e.splice(a.$index,1)})},$.support.transition&&i.hasClass("fade")?i.on($.support.transition.end,e):e()):s&&(t.preventDefault(),i.removeClass("in"),e=function(){i.trigger("closed"),a.$apply(function(){s.closed=!0})},$.support.transition&&i.hasClass("fade")?i.on($.support.transition.end,e):e())})}}}]),angular.module("$strap.directives").directive("bsButton",["$parse","$timeout",function(t){return{restrict:"A",require:"?ngModel",link:function(e,n,a,i){if(i){n.parent('[data-toggle="buttons-checkbox"], [data-toggle="buttons-radio"]').length||n.attr("data-toggle","button");var o=!!e.$eval(a.ngModel);o&&n.addClass("active"),e.$watch(a.ngModel,function(t,e){var a=!!t,i=!!e;a!==i?$.fn.button.Constructor.prototype.toggle.call(r):a&&!o&&n.addClass("active")})}n.hasClass("btn")||n.on("click.button.data-api",function(){n.button("toggle")}),n.button();var r=n.data("button");r.toggle=function(){if(!i)return $.fn.button.Constructor.prototype.toggle.call(this);var a=n.parent('[data-toggle="buttons-radio"]');a.length?(n.siblings("[ng-model]").each(function(n,a){t($(a).attr("ng-model")).assign(e,!1)}),e.$digest(),i.$modelValue||(i.$setViewValue(!i.$modelValue),e.$digest())):e.$apply(function(){i.$setViewValue(!i.$modelValue)})}}}}]).directive("bsButtonsCheckbox",["$parse",function(){return{restrict:"A",require:"?ngModel",compile:function(t){t.attr("data-toggle","buttons-checkbox").find("a, button").each(function(t,e){$(e).attr("bs-button","")})}}}]).directive("bsButtonsRadio",["$timeout",function(t){return{restrict:"A",require:"?ngModel",compile:function(e,n){return e.attr("data-toggle","buttons-radio"),n.ngModel||e.find("a, button").each(function(t,e){$(e).attr("bs-button","")}),function(e,n,a,i){i&&(t(function(){n.find("[value]").button().filter('[value="'+i.$viewValue+'"]').addClass("active")}),n.on("click.button.data-api",function(t){e.$apply(function(){i.$setViewValue($(t.target).closest("button").attr("value"))})}),e.$watch(a.ngModel,function(t,i){if(t!==i){var o=n.find('[value="'+e.$eval(a.ngModel)+'"]');o.length&&o.button("toggle")}}))}}}}]),angular.module("$strap.directives").directive("bsButtonSelect",["$parse","$timeout",function(t){return{restrict:"A",require:"?ngModel",link:function(e,n,a,i){var o=t(a.bsButtonSelect);o.assign,i&&(n.text(e.$eval(a.ngModel)),e.$watch(a.ngModel,function(t){n.text(t)}));var r,s,l,u;n.bind("click",function(){r=o(e),s=i?e.$eval(a.ngModel):n.text(),l=r.indexOf(s),u=l>r.length-2?r[0]:r[l+1],e.$apply(function(){n.text(u),i&&i.$setViewValue(u)})})}}}]),angular.module("$strap.directives").directive("bsDatepicker",["$timeout","$strapConfig",function(t,e){var n=/(iP(a|o)d|iPhone)/g.test(navigator.userAgent),a=function a(t){return t=t||"en",{"/":"[\\/]","-":"[-]",".":"[.]"," ":"[\\s]",dd:"(?:(?:[0-2]?[0-9]{1})|(?:[3][01]{1}))",d:"(?:(?:[0-2]?[0-9]{1})|(?:[3][01]{1}))",mm:"(?:[0]?[1-9]|[1][012])",m:"(?:[0]?[1-9]|[1][012])",DD:"(?:"+$.fn.datepicker.dates[t].days.join("|")+")",D:"(?:"+$.fn.datepicker.dates[t].daysShort.join("|")+")",MM:"(?:"+$.fn.datepicker.dates[t].months.join("|")+")",M:"(?:"+$.fn.datepicker.dates[t].monthsShort.join("|")+")",yyyy:"(?:(?:[1]{1}[0-9]{1}[0-9]{1}[0-9]{1})|(?:[2]{1}[0-9]{3}))(?![[0-9]])",yy:"(?:(?:[0-9]{1}[0-9]{1}))(?![[0-9]])"}},i=function i(t,e){var n,i=t,o=a(e);return n=0,angular.forEach(o,function(t,e){i=i.split(e).join("${"+n+"}"),n++}),n=0,angular.forEach(o,function(t){i=i.split("${"+n+"}").join(t),n++}),RegExp("^"+i+"$",["i"])};return{restrict:"A",require:"?ngModel",link:function(t,a,o,r){var s=angular.extend({autoclose:!0},e.datepicker||{}),l=o.dateType||s.type||"date";angular.forEach(["format","weekStart","calendarWeeks","startDate","endDate","daysOfWeekDisabled","autoclose","startView","minViewMode","todayBtn","todayHighlight","keyboardNavigation","language","forceParse"],function(t){angular.isDefined(o[t])&&(s[t]=o[t])});var u=s.language||"en",c=o.dateFormat||s.format||$.fn.datepicker.dates[u]&&$.fn.datepicker.dates[u].format||"mm/dd/yyyy",d=n?"yyyy-mm-dd":c,p=i(d,u);r&&(r.$formatters.unshift(function(t){return"date"===l&&angular.isString(t)&&t?$.fn.datepicker.DPGlobal.parseDate(t,$.fn.datepicker.DPGlobal.parseFormat(c),u):t}),r.$parsers.unshift(function(t){return t?"date"===l&&angular.isDate(t)?(r.$setValidity("date",!0),t):angular.isString(t)&&p.test(t)?(r.$setValidity("date",!0),n?new Date(t):"string"===l?t:$.fn.datepicker.DPGlobal.parseDate(t,$.fn.datepicker.DPGlobal.parseFormat(d),u)):(r.$setValidity("date",!1),void 0):(r.$setValidity("date",!0),null)}),r.$render=function(){if(n){var t=r.$viewValue?$.fn.datepicker.DPGlobal.formatDate(r.$viewValue,$.fn.datepicker.DPGlobal.parseFormat(d),u):"";return a.val(t),t}return r.$viewValue||a.val(""),a.datepicker("update",r.$viewValue)}),n?a.prop("type","date").css("-webkit-appearance","textfield"):(r&&a.on("changeDate",function(e){t.$apply(function(){r.$setViewValue("string"===l?a.val():e.date)})}),a.datepicker(angular.extend(s,{format:d,language:u})),t.$on("$destroy",function(){var t=a.data("datepicker");t&&(t.picker.remove(),a.data("datepicker",null))}),o.$observe("startDate",function(t){a.datepicker("setStartDate",t)}),o.$observe("endDate",function(t){a.datepicker("setEndDate",t)}));var f=a.siblings('[data-toggle="datepicker"]');f.length&&f.on("click",function(){a.prop("disabled")||a.trigger("focus")})}}}]),angular.module("$strap.directives").directive("bsDropdown",["$parse","$compile","$timeout",function(t,e,n){var a=function(t,e){return e||(e=['"]),angular.forEach(t,function(t,n){if(t.divider)return e.splice(n+1,0,'
  • ');var i=""+'"+(t.text||"")+"";t.submenu&&t.submenu.length&&(i+=a(t.submenu).join("\n")),i+="",e.splice(n+1,0,i)}),e};return{restrict:"EA",scope:!0,link:function(i,o,r){var s=t(r.bsDropdown),l=s(i);n(function(){!angular.isArray(l);var t=angular.element(a(l).join(""));t.insertAfter(o),e(o.next("ul.dropdown-menu"))(i)}),o.addClass("dropdown-toggle").attr("data-toggle","dropdown")}}}]),angular.module("$strap.directives").factory("$modal",["$rootScope","$compile","$http","$timeout","$q","$templateCache","$strapConfig",function(t,e,n,a,i,o,r){var s=function s(s){function l(s){var l=angular.extend({show:!0},r.modal,s),u=l.scope?l.scope:t.$new(),c=l.template;return i.when(o.get(c)||n.get(c,{cache:!0}).then(function(t){return t.data})).then(function(t){var n=c.replace(".html","").replace(/[\/|\.|:]/g,"-")+"-"+u.$id,i=$('').attr("id",n).addClass("fade").html(t);return l.modalClass&&i.addClass(l.modalClass),$("body").append(i),a(function(){e(i)(u)}),u.$modal=function(t){i.modal(t)},angular.forEach(["show","hide"],function(t){u[t]=function(){i.modal(t)}}),u.dismiss=u.hide,angular.forEach(["show","shown","hide","hidden"],function(t){i.on(t,function(e){u.$emit("modal-"+t,e)})}),i.on("shown",function(){$("input[autofocus], textarea[autofocus]",i).first().trigger("focus")}),i.on("hidden",function(){l.persist||u.$destroy()}),u.$on("$destroy",function(){i.remove()}),i.modal(l),i})}return new l(s)};return s}]).directive("bsModal",["$q","$modal",function(t,e){return{restrict:"A",scope:!0,link:function(n,a,i){var o={template:n.$eval(i.bsModal),persist:!0,show:!1,scope:n};angular.forEach(["modalClass","backdrop","keyboard"],function(t){angular.isDefined(i[t])&&(o[t]=i[t])}),t.when(e(o)).then(function(t){a.attr("data-target","#"+t.attr("id")).attr("data-toggle","modal")})}}}]),angular.module("$strap.directives").directive("bsNavbar",["$location",function(t){return{restrict:"A",link:function(e,n){e.$watch(function(){return t.path()},function(t){$("li[data-match-route]",n).each(function(e,n){var a=angular.element(n),i=a.attr("data-match-route"),o=RegExp("^"+i+"$",["i"]);o.test(t)?a.addClass("active").find(".collapse.in").collapse("hide"):a.removeClass("active")})})}}}]),angular.module("$strap.directives").directive("bsPopover",["$parse","$compile","$http","$timeout","$q","$templateCache",function(t,e,n,a,i,o){return $("body").on("keyup",function(t){27===t.keyCode&&$(".popover.in").each(function(){$(this).popover("hide")})}),{restrict:"A",scope:!0,link:function(r,s,l){var u=t(l.bsPopover),c=(u.assign,u(r)),d={};angular.isObject(c)&&(d=c),i.when(d.content||o.get(c)||n.get(c,{cache:!0})).then(function(t){angular.isObject(t)&&(t=t.data),l.unique&&s.on("show",function(){$(".popover.in").each(function(){var t=$(this),e=t.data("bs.popover");e&&!e.$element.is(s)&&t.popover("hide")})}),l.hide&&r.$watch(l.hide,function(t,e){t?n.hide():t!==e&&n.show()}),l.show&&r.$watch(l.show,function(t,e){t?a(function(){n.show()}):t!==e&&n.hide()}),s.popover(angular.extend({},d,{content:t,html:!0}));var n=s.data("bs.popover");n.hasContent=function(){return this.getTitle()||t},n.getPosition=function(){var t=$.fn.popover.Constructor.prototype.getPosition.apply(this,arguments);return e(this.$tip)(r),r.$digest(),this.$tip.data("bs.popover",this),t},r.$popover=function(t){n(t)},angular.forEach(["show","hide"],function(t){r[t]=function(){n[t]()}}),r.dismiss=r.hide,angular.forEach(["show","shown","hide","hidden"],function(t){s.on(t,function(e){r.$emit("popover-"+t,e)})})})}}}]),angular.module("$strap.directives").directive("bsSelect",["$timeout",function(t){return{restrict:"A",require:"?ngModel",link:function(e,n,a,i){var o=e.$eval(a.bsSelect)||{};t(function(){n.selectpicker(o),n.next().removeClass("ng-scope")}),i&&e.$watch(a.ngModel,function(t,e){angular.equals(t,e)||n.selectpicker("refresh")})}}}]),angular.module("$strap.directives").directive("bsTabs",["$parse","$compile","$timeout",function(t,e,n){var a='
    ';return{restrict:"A",require:"?ngModel",priority:0,scope:!0,template:a,replace:!0,transclude:!0,compile:function(){return function(e,a,i,o){var r=t(i.bsTabs);r.assign,r(e),e.panes=[];var s,l,u,c=a.find("ul.nav-tabs"),d=a.find("div.tab-content"),p=0;n(function(){d.find("[data-title], [data-tab]").each(function(t){var n=angular.element(this);s="tab-"+e.$id+"-"+t,l=n.data("title")||n.data("tab"),u=!u&&n.hasClass("active"),n.attr("id",s).addClass("tab-pane"),i.fade&&n.addClass("fade"),e.panes.push({id:s,title:l,content:this.innerHTML,active:u})}),e.panes.length&&!u&&(d.find(".tab-pane:first-child").addClass("active"+(i.fade?" in":"")),e.panes[0].active=!0)}),o&&(a.on("show",function(t){var n=$(t.target);e.$apply(function(){o.$setViewValue(n.data("index"))})}),e.$watch(i.ngModel,function(t){angular.isUndefined(t)||(p=t,setTimeout(function(){var e=$(c[0].querySelectorAll("li")[1*t]);e.hasClass("active")||e.children("a").tab("show")}))}))}}}}]),angular.module("$strap.directives").directive("bsTimepicker",["$timeout","$strapConfig",function(t,e){var n="((?:(?:[0-1][0-9])|(?:[2][0-3])|(?:[0-9])):(?:[0-5][0-9])(?::[0-5][0-9])?(?:\\s?(?:am|AM|pm|PM))?)";return{restrict:"A",require:"?ngModel",link:function(a,i,o,r){if(r){i.on("changeTime.timepicker",function(){t(function(){r.$setViewValue(i.val())})});var s=RegExp("^"+n+"$",["i"]);r.$parsers.unshift(function(t){return!t||s.test(t)?(r.$setValidity("time",!0),t):(r.$setValidity("time",!1),void 0)})}i.attr("data-toggle","timepicker"),i.parent().addClass("bootstrap-timepicker"),i.timepicker(e.timepicker||{});var l=i.data("timepicker"),u=i.siblings('[data-toggle="timepicker"]');u.length&&u.on("click",$.proxy(l.showWidget,l))}}}]),angular.module("$strap.directives").directive("bsTooltip",["$parse","$compile",function(t){return{restrict:"A",scope:!0,link:function(e,n,a){var i=t(a.bsTooltip),o=(i.assign,i(e));e.$watch(a.bsTooltip,function(t,e){t!==e&&(o=t)}),a.unique&&n.on("show",function(){$(".tooltip.in").each(function(){var t=$(this),e=t.data("bs.tooltip");e&&!e.$element.is(n)&&t.tooltip("hide")})}),n.tooltip({title:function(){return angular.isFunction(o)?o.apply(null,arguments):o},html:!0});var r=n.data("bs.tooltip");r.show=function(){var t=$.fn.tooltip.Constructor.prototype.show.apply(this,arguments);return this.tip().data("bs.tooltip",this),t},e._tooltip=function(t){n.tooltip(t)},e.hide=function(){n.tooltip("hide")},e.show=function(){n.tooltip("show")},e.dismiss=e.hide}}}]),angular.module("$strap.directives").directive("bsTypeahead",["$parse",function(t){return{restrict:"A",require:"?ngModel",link:function(e,n,a,i){var o=t(a.bsTypeahead),r=(o.assign,o(e));e.$watch(a.bsTypeahead,function(t,e){t!==e&&(r=t)}),n.attr("data-provide","typeahead"),n.typeahead({source:function(){return angular.isFunction(r)?r.apply(null,arguments):r},minLength:a.minLength||1,items:a.items,updater:function(t){return i&&e.$apply(function(){i.$setViewValue(t)}),e.$emit("typeahead-updated",t),t}});var s=n.data("typeahead");s.lookup=function(){var t;return this.query=this.$element.val()||"",this.query.length + + diff --git a/static/partials/guide.html b/static/partials/guide.html index 70e5f459f..592ecc3a4 100644 --- a/static/partials/guide.html +++ b/static/partials/guide.html @@ -1,7 +1,7 @@
    Warning: Quay requires docker version 0.6.2 or higher to work
    -

    User guide

    +

    User Guide

    Pulling a repository from Quay

    diff --git a/static/partials/header.html b/static/partials/header.html index 2fa772a4f..34e2d2231 100644 --- a/static/partials/header.html +++ b/static/partials/header.html @@ -15,8 +15,9 @@ -
    +
    + +
    diff --git a/static/partials/landing.html b/static/partials/landing.html index 4e3426972..d182bc7d2 100644 --- a/static/partials/landing.html +++ b/static/partials/landing.html @@ -12,22 +12,22 @@
    +
    -

    Your Top Repositories

    +

    Top Repositories

    -
    - You don't have any private repositories yet! - +
    + You don't have access to any repositories in this organization yet. + You don't have any repositories yet!
    diff --git a/static/partials/new-organization.html b/static/partials/new-organization.html new file mode 100644 index 000000000..955c0d2d0 --- /dev/null +++ b/static/partials/new-organization.html @@ -0,0 +1,102 @@ +
    + +
    + +
    + +
    +
    +

    Create Organization

    + +
    +
      +
    • + + Login with an account +
    • +
    • + + Setup your organization +
    • +
    • + + Create teams +
    • +
    +
    + +
    +
    + + +
    +
    + In order to create a new organization, you must first be signed in as the + user that will become an admin for the organization. Please sign-in if + you already have an account, or sign up on the landing + page to create a new account. +
    +
    +
    +
    +
    +

    Sign In

    +
    +
    + +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +

    Setup the new organization

    + +
    +
    + + + This will also be the namespace for your repositories +
    + +
    + + + This address must be different from your account's email +
    + + +
    + Choose your organization's plan +
    +
    + +
    + +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +

    Organization Created

    +

    Manage Teams Now

    +
    +
    +
    +
    diff --git a/static/partials/new-repo.html b/static/partials/new-repo.html index 30cdd2316..393d7003c 100644 --- a/static/partials/new-repo.html +++ b/static/partials/new-repo.html @@ -20,7 +20,7 @@
    -
    +
    @@ -28,17 +28,19 @@
    - - {{user.username}} / + + + / + + +
    Description:
    -

    - - -

    +
    @@ -68,13 +70,19 @@
    -
    +
    In order to make this repository private, you’ll need to upgrade your plan from {{ subscribedPlan.title }} to {{ planRequired.title }}. This will cost ${{ planRequired.price / 100 }}/month.
    Upgrade now
    + +
    +
    + This organization has reached its private repository limit. Please contact your administrator. +
    +
    diff --git a/static/partials/org-admin.html b/static/partials/org-admin.html new file mode 100644 index 000000000..c9811e82a --- /dev/null +++ b/static/partials/org-admin.html @@ -0,0 +1,67 @@ +
    + +
    + +
    + No matching organization found +
    + +
    +
    + +
    + + + + +
    +
    + +
    +
    +
    + + +
    + + +
    +
    +
    + Showing {{(membersFound | filter:search | limitTo:50).length}} of {{(membersFound | filter:search).length}} matching members +
    +
    + +
    +
    + + + + + + + + + + + +
    UserTeams
    + + {{ memberInfo.username }} + + + + {{ team }} + +
    +
    +
    +
    +
    +
    +
    diff --git a/static/partials/org-view.html b/static/partials/org-view.html new file mode 100644 index 000000000..c1011d74f --- /dev/null +++ b/static/partials/org-view.html @@ -0,0 +1,86 @@ +
    + +
    + +
    + No matching organization found +
    + +
    +
    +
    + + Settings +
    +
    + + + +
    +
    +
    +
    + + + {{ team.name }} + + + {{ team.name }} + +
    + +
    +
    + +
    + + +
    +
    +
    +
    + + + + + + + diff --git a/static/partials/organizations.html b/static/partials/organizations.html new file mode 100644 index 000000000..b6ff064cc --- /dev/null +++ b/static/partials/organizations.html @@ -0,0 +1,129 @@ +
    +
    + +
    + + + + +
    +

    Organizations

    + + +
    + + +
    + +
    +
    +
    Organizations
    +
    + Organizations in Quay provide unique features for businesses and other + groups, including team-based sharing and fine-grained permission controls. +
    +
    +
    + + +
    +
    +
    +
    A central collection of repositories
    +
    + 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. +
    +
    +
    + +
    +
    +
    +
    Organization settings at a glance
    +
    + Your organization allows you to view your private repository count + and manage billing settings in a centralized place. +
    +
    + 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. +
    +
    +
    + +
    +
    +
    +
    Teams simplify access controls
    +
    + 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. +
    +
    + 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. +
    +
    +
    + +
    +
    +
    +
    Fine-grained control of sharing
    +
    + 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. +
    +
    + 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. +
    +
    +
    + + +
    +
    diff --git a/static/partials/plans.html b/static/partials/plans.html index 84885b790..24ac8b1e4 100644 --- a/static/partials/plans.html +++ b/static/partials/plans.html @@ -7,15 +7,39 @@ All plans include unlimited public repositories and unlimited sharing. All paid plans have a 14-day free trial.
    -
    -
    -
    {{ plan.title }}
    -
    ${{ plan.price/100 }}
    -
    {{ plan.privateRepos }} private repositories
    -
    {{ plan.audience }}
    -
    SSL secured connections
    +
    +
    +
    +
    +
    {{ plan.title }}
    +
    ${{ plan.price/100 }}
    +
    {{ plan.privateRepos }} private repositories
    +
    {{ plan.audience }}
    +
    SSL secured connections
    + +
    +
    +
    - +
    + Business Plan Pricing +
    + +
    + All business plans include all of the personal plan features, plus: organizations and teams with delegated access to the organization. All business plans have a 14-day free trial. +
    + +
    +
    +
    +
    +
    {{ plan.title }}
    +
    ${{ plan.price/100 }}
    +
    {{ plan.privateRepos }} private repositories
    +
    {{ plan.audience }}
    +
    SSL secured connections
    + +
    diff --git a/static/partials/repo-admin.html b/static/partials/repo-admin.html index e3d341cd3..d0a0e0d4f 100644 --- a/static/partials/repo-admin.html +++ b/static/partials/repo-admin.html @@ -20,44 +20,61 @@
    -
    User Access Permissions +
    User and Team Access Permissions - +
    - + - + - - + + + + + + + +
    UserUser/Team Permissions
    + +
    + + {{name}} + + + + + + + +
    - {{username}} + {{name}} +
    - - - +
    - - + +
    - +
    @@ -93,9 +110,9 @@
    - + - + @@ -246,7 +263,7 @@ -