From bca0d290108d15ab532a618a78145c270c825b7b Mon Sep 17 00:00:00 2001 From: Pradeep Kumar Date: Sat, 19 Sep 2020 20:56:27 +0530 Subject: [PATCH] Release v1.0.0 --- .gitignore | 27 + LICENSE | 2 +- README.md | 5 +- api/.env.example | 14 + api/.gitignore | 136 + api/.rubocop.yml | 97 + api/.ruby-gemset | 1 + api/.ruby-version | 1 + api/Gemfile | 103 + api/Gemfile.lock | 428 + api/Guardfile | 42 + api/Procfile | 2 + api/README.md | 51 + api/Rakefile | 7 + api/app/assets/config/manifest.js | 3 + api/app/assets/images/.keep | 0 api/app/assets/javascripts/application.js | 15 + api/app/assets/javascripts/cable.js | 13 + api/app/assets/javascripts/channels/.keep | 0 api/app/assets/stylesheets/application.css | 15 + api/app/channels/application_cable/channel.rb | 4 + .../channels/application_cable/connection.rb | 4 + api/app/controllers/application_controller.rb | 3 + .../base_current_project_controller.rb | 27 + .../base_current_user_controller.rb | 29 + .../controllers/concerns/api_error_handler.rb | 68 + api/app/controllers/graphql_controller.rb | 62 + api/app/controllers/v1/content_controller.rb | 42 + api/app/controllers/v1/entities_controller.rb | 15 + api/app/graphql/clay_api_schema.rb | 6 + .../graphql/functions/application_function.rb | 12 + api/app/graphql/graphql_policy.rb | 38 + api/app/graphql/mutations/.keep | 0 .../accept_transfer_request_mutator.rb | 22 + .../graphql/mutators/application_mutator.rb | 9 + .../cancel_transfer_request_mutator.rb | 14 + .../graphql/mutators/clone_record_mutator.rb | 13 + .../graphql/mutators/create_asset_mutator.rb | 21 + .../graphql/mutators/create_entity_mutator.rb | 28 + .../graphql/mutators/create_field_mutator.rb | 30 + .../mutators/create_key_pair_mutator.rb | 19 + .../mutators/create_project_mutator.rb | 20 + .../graphql/mutators/create_record_mutator.rb | 20 + .../mutators/create_resource_mutator.rb | 21 + .../create_team_membership_mutator.rb | 21 + .../graphql/mutators/create_team_mutator.rb | 16 + .../create_transfer_request_mutator.rb | 21 + .../graphql/mutators/destroy_asset_mutator.rb | 13 + .../mutators/destroy_entity_mutator.rb | 13 + .../graphql/mutators/destroy_field_mutator.rb | 13 + .../mutators/destroy_record_mutator.rb | 13 + .../mutators/destroy_resource_mutator.rb | 13 + .../destroy_team_membership_mutator.rb | 13 + .../mutators/export_project_mutator.rb | 13 + .../mutators/import_project_mutator.rb | 20 + .../reject_transfer_request_mutator.rb | 22 + .../mutators/revoke_key_pair_mutator.rb | 13 + .../graphql/mutators/sort_fields_mutator.rb | 31 + .../graphql/mutators/sso_callback_mutator.rb | 20 + api/app/graphql/mutators/sso_login_mutator.rb | 8 + .../graphql/mutators/sso_logout_mutator.rb | 15 + .../graphql/mutators/update_asset_mutator.rb | 20 + .../graphql/mutators/update_entity_mutator.rb | 28 + .../graphql/mutators/update_field_mutator.rb | 30 + .../mutators/update_profile_mutator.rb | 19 + .../mutators/update_project_mutator.rb | 20 + .../graphql/mutators/update_record_mutator.rb | 20 + .../mutators/update_resource_mutator.rb | 20 + .../update_team_membership_mutator.rb | 20 + .../graphql/mutators/update_team_mutator.rb | 20 + .../graphql/resolvers/application_resolver.rb | 39 + api/app/graphql/resolvers/assets_resolver.rb | 10 + .../resolvers/current_user_resolver.rb | 7 + .../graphql/resolvers/entities_resolver.rb | 10 + api/app/graphql/resolvers/entity_resolver.rb | 10 + api/app/graphql/resolvers/exports_resolver.rb | 10 + api/app/graphql/resolvers/fields_resolver.rb | 10 + .../graphql/resolvers/key_pairs_resolver.rb | 10 + api/app/graphql/resolvers/project_resolver.rb | 10 + .../graphql/resolvers/projects_resolver.rb | 13 + api/app/graphql/resolvers/record_resolver.rb | 10 + api/app/graphql/resolvers/records_resolver.rb | 10 + .../resolvers/referenced_entities_resolver.rb | 12 + .../graphql/resolvers/resources_resolver.rb | 10 + .../graphql/resolvers/restores_resolver.rb | 10 + .../resolvers/team_memberships_resolver.rb | 12 + api/app/graphql/resolvers/team_resolver.rb | 7 + api/app/graphql/resolvers/teams_resolver.rb | 8 + api/app/graphql/roots/mutation_type.rb | 52 + api/app/graphql/roots/query_type.rb | 39 + api/app/graphql/scalars/base_scalar_type.rb | 4 + api/app/graphql/scalars/file_type.rb | 13 + api/app/graphql/scalars/hash_type.rb | 13 + api/app/graphql/scalars/json_type.rb | 13 + api/app/graphql/types/application_type.rb | 19 + api/app/graphql/types/asset_type.rb | 12 + api/app/graphql/types/entity_type.rb | 14 + api/app/graphql/types/export_type.rb | 13 + api/app/graphql/types/field_type.rb | 22 + api/app/graphql/types/key_pair_type.rb | 9 + api/app/graphql/types/locale_type.rb | 9 + api/app/graphql/types/project_type.rb | 12 + api/app/graphql/types/property_type.rb | 16 + api/app/graphql/types/record_type.rb | 9 + api/app/graphql/types/relationship_type.rb | 12 + api/app/graphql/types/resource_type.rb | 12 + api/app/graphql/types/response_type.rb | 5 + api/app/graphql/types/restore_type.rb | 10 + api/app/graphql/types/team_membership_type.rb | 10 + api/app/graphql/types/team_type.rb | 11 + api/app/graphql/types/user_type.rb | 14 + api/app/helpers/application_helper.rb | 2 + .../interactors/accept_transfer_request.rb | 24 + api/app/interactors/authenticate_user.rb | 30 + .../interactors/cancel_transfer_request.rb | 7 + api/app/interactors/clone_record.rb | 8 + api/app/interactors/create_asset.rb | 7 + api/app/interactors/create_entity.rb | 7 + api/app/interactors/create_field.rb | 53 + api/app/interactors/create_key_pair.rb | 7 + api/app/interactors/create_project.rb | 10 + api/app/interactors/create_record.rb | 110 + api/app/interactors/create_resource.rb | 21 + api/app/interactors/create_team.rb | 23 + api/app/interactors/create_team_membership.rb | 34 + .../interactors/create_transfer_request.rb | 27 + api/app/interactors/destroy_asset.rb | 7 + api/app/interactors/destroy_entity.rb | 7 + api/app/interactors/destroy_field.rb | 7 + api/app/interactors/destroy_record.rb | 7 + api/app/interactors/destroy_resource.rb | 7 + .../interactors/destroy_team_membership.rb | 24 + api/app/interactors/export_project.rb | 23 + api/app/interactors/import_project.rb | 23 + api/app/interactors/perform_export.rb | 224 + api/app/interactors/perform_restore.rb | 339 + .../interactors/reject_transfer_request.rb | 18 + api/app/interactors/revoke_key_pair.rb | 7 + api/app/interactors/sort_fields.rb | 15 + api/app/interactors/sso_callback.rb | 73 + api/app/interactors/sso_login.rb | 34 + api/app/interactors/sso_logout.rb | 8 + api/app/interactors/update_asset.rb | 7 + api/app/interactors/update_entity.rb | 7 + api/app/interactors/update_field.rb | 56 + api/app/interactors/update_project.rb | 7 + api/app/interactors/update_record.rb | 109 + api/app/interactors/update_resource.rb | 7 + api/app/interactors/update_team.rb | 7 + api/app/interactors/update_team_membership.rb | 9 + api/app/interactors/update_user.rb | 7 + api/app/jobs/application_job.rb | 2 + api/app/mailers/application_mailer.rb | 4 + api/app/models/application_record.rb | 3 + api/app/models/asset.rb | 35 + api/app/models/auth_nonce.rb | 18 + api/app/models/auth_token.rb | 18 + api/app/models/concerns/nested_fetchable.rb | 23 + api/app/models/concerns/tokenable.rb | 39 + api/app/models/concerns/transferable.rb | 35 + api/app/models/concerns/uid.rb | 15 + api/app/models/entity.rb | 17 + api/app/models/export.rb | 11 + api/app/models/field.rb | 35 + api/app/models/key_pair.rb | 31 + api/app/models/locale.rb | 5 + api/app/models/project.rb | 16 + api/app/models/property.rb | 19 + api/app/models/record.rb | 40 + api/app/models/relationship.rb | 7 + api/app/models/resource.rb | 18 + api/app/models/restore.rb | 8 + api/app/models/team.rb | 12 + api/app/models/team_membership.rb | 29 + api/app/models/user.rb | 15 + api/app/policies/application_policy.rb | 53 + api/app/policies/asset_policy.rb | 15 + api/app/policies/base_member_policy.rb | 13 + api/app/policies/entity_policy.rb | 29 + api/app/policies/field_policy.rb | 19 + api/app/policies/key_pair_policy.rb | 11 + api/app/policies/project_policy.rb | 63 + api/app/policies/record_policy.rb | 19 + api/app/policies/resource_policy.rb | 15 + api/app/policies/team_membership_policy.rb | 17 + api/app/policies/team_policy.rb | 51 + api/app/publishers/base_publisher.rb | 21 + .../publishers/export_project_publisher.rb | 3 + .../publishers/import_project_publisher.rb | 3 + api/app/uploaders/asset_file_uploader.rb | 25 + api/app/uploaders/base_uploader.rb | 3 + api/app/uploaders/export_file_uploader.rb | 4 + api/app/uploaders/image_uploader.rb | 2 + api/app/uploaders/profile_picture_uploader.rb | 16 + api/app/uploaders/resource_file_uploader.rb | 21 + api/app/validators/email_format_validator.rb | 7 + api/app/views/layouts/application.html.erb | 15 + api/app/views/layouts/mailer.html.erb | 13 + api/app/views/layouts/mailer.text.erb | 1 + api/app/workers/export_project_worker.rb | 26 + api/app/workers/import_project_worker.rb | 26 + api/bin/bundle | 3 + api/bin/rails | 9 + api/bin/rake | 9 + api/bin/scripts/clay | 3 + api/bin/scripts/setup-db-dev | 12 + api/bin/scripts/start-app | 10 + api/bin/setup | 36 + api/bin/spring | 17 + api/bin/update | 31 + api/bin/yarn | 11 + api/config.ru | 18 + api/config/application.rb | 41 + api/config/boot.rb | 4 + api/config/cable.yml | 10 + api/config/credentials.yml.enc | 1 + api/config/database.yml.example | 23 + api/config/environment.rb | 5 + api/config/environments/development.rb | 57 + api/config/environments/production.rb | 116 + api/config/environments/staging.rb | 116 + api/config/environments/test.rb | 46 + api/config/initializers/00_require.rb | 11 + .../application_controller_renderer.rb | 8 + .../initializers/backtrace_silencers.rb | 7 + api/config/initializers/bullet.rb | 4 + .../initializers/content_security_policy.rb | 25 + api/config/initializers/cookies_serializer.rb | 5 + .../initializers/filter_parameter_logging.rb | 4 + .../initializers/graphql_rails_logger.rb | 5 + api/config/initializers/inflections.rb | 16 + api/config/initializers/mime_types.rb | 4 + api/config/initializers/raven.rb | 8 + api/config/initializers/shrine.rb | 49 + api/config/initializers/sneakers.rb | 11 + api/config/initializers/wrap_parameters.rb | 14 + api/config/locales/en.yml | 33 + api/config/puma.rb | 34 + api/config/routes.rb | 12 + api/config/spring.rb | 6 + api/config/storage.yml | 34 + api/db/schema.rb | 244 + api/db/seeds.rb | 52 + api/fixtures/assets/logo.png | Bin 0 -> 2401 bytes api/lib/app_url.rb | 7 + api/lib/credentials.rb | 5 + api/lib/diff.rb | 75 + api/lib/exceptions.rb | 52 + api/lib/gb_logger.rb | 63 + api/lib/json_web_token.rb | 9 + api/lib/mail_address.rb | 8 + api/lib/rabbit_mq.rb | 5 + api/lib/record_mapper.rb | 92 + api/lib/tasks/project.rake | 155 + api/lib/token.rb | 15 + api/lib/uid_generator.rb | 21 + api/lib/url.rb | 10 + api/log/.keep | 0 api/package.json | 5 + api/public/404.html | 67 + api/public/422.html | 67 + api/public/500.html | 66 + api/public/apple-touch-icon-precomposed.png | 0 api/public/apple-touch-icon.png | 0 api/public/favicon.ico | 0 api/public/robots.txt | 4 + api/spec/factories/assets.rb | 8 + api/spec/factories/auth_nonces.rb | 6 + api/spec/factories/auth_tokens.rb | 7 + api/spec/factories/entities.rb | 8 + api/spec/factories/exports.rb | 4 + api/spec/factories/fields.rb | 9 + api/spec/factories/key_pairs.rb | 5 + api/spec/factories/projects.rb | 7 + api/spec/factories/records.rb | 5 + api/spec/factories/resources.rb | 4 + api/spec/factories/restores.rb | 4 + api/spec/factories/team_memberships.rb | 7 + api/spec/factories/teams.rb | 5 + api/spec/factories/users.rb | 9 + .../accept_transfer_request_spec.rb | 72 + .../interactors/authenticate_user_spec.rb | 59 + .../cancel_transfer_request_spec.rb | 21 + api/spec/interactors/create_asset_spec.rb | 34 + api/spec/interactors/create_entity_spec.rb | 36 + api/spec/interactors/create_key_pair_spec.rb | 15 + api/spec/interactors/create_project_spec.rb | 41 + .../create_team_membership_spec.rb | 73 + api/spec/interactors/create_team_spec.rb | 35 + .../create_transfer_request_spec.rb | 125 + api/spec/interactors/destroy_asset_spec.rb | 12 + api/spec/interactors/destroy_entity_spec.rb | 12 + .../destroy_team_membership_spec.rb | 32 + .../reject_transfer_request_spec.rb | 72 + api/spec/interactors/revoke_key_pair_spec.rb | 19 + api/spec/interactors/sso_callback_spec.rb | 66 + api/spec/interactors/sso_login_spec.rb | 24 + api/spec/interactors/update_asset_spec.rb | 32 + api/spec/interactors/update_entity_spec.rb | 31 + api/spec/interactors/update_project_spec.rb | 31 + .../update_team_membership_spec.rb | 42 + api/spec/interactors/update_team_spec.rb | 30 + api/spec/interactors/update_user_spec.rb | 51 + api/spec/lib/app_url_spec.rb | 57 + api/spec/models/asset_spec.rb | 12 + api/spec/models/auth_nonce_spec.rb | 5 + api/spec/models/auth_token_spec.rb | 5 + api/spec/models/entity_spec.rb | 15 + api/spec/models/export_spec.rb | 5 + api/spec/models/field_spec.rb | 16 + api/spec/models/key_pair_spec.rb | 54 + api/spec/models/locale_spec.rb | 11 + api/spec/models/project_spec.rb | 18 + api/spec/models/property_spec.rb | 8 + api/spec/models/record_spec.rb | 13 + api/spec/models/relationship_spec.rb | 10 + api/spec/models/resource_spec.rb | 12 + api/spec/models/restore_spec.rb | 5 + api/spec/models/team_membership_spec.rb | 12 + api/spec/models/team_spec.rb | 15 + api/spec/models/user_spec.rb | 17 + api/spec/policies/asset_policy_spec.rb | 14 + api/spec/policies/entity_policy_spec.rb | 14 + api/spec/policies/field_policy_spec.rb | 15 + api/spec/policies/key_pair_policy_spec.rb | 14 + api/spec/policies/project_policy_spec.rb | 14 + api/spec/policies/record_policy_spec.rb | 15 + .../policies/team_membership_policy_spec.rb | 9 + api/spec/policies/team_policy_spec.rb | 11 + api/spec/rails_helper.rb | 66 + api/spec/spec_helper.rb | 109 + api/spec/support/concerns/transferable.rb | 130 + api/spec/support/geocoder_helper.rb | 22 + api/spec/support/policy.rb | 22 + api/tmp/.keep | 0 api/vendor/.keep | 0 ui/.babelrc | 21 + ui/.env.example | 9 + ui/.env.heroku | 1 + ui/.eslintignore | 2 + ui/.eslintrc | 36 + ui/.gitignore | 85 + ui/LICENSE.md | 1 + ui/README.md | 38 + ui/netlify.toml | 4 + ui/package.json | 128 + ui/src/Root.js | 34 + ui/src/assets/fonts/claycms-icons.woff | Bin 0 -> 12660 bytes ui/src/assets/fonts/claycms-icons.woff2 | Bin 0 -> 9716 bytes .../external/footer-background-mobile.svg | 13 + .../images/external/footer-background.svg | 13 + ui/src/assets/images/external/page-circle.png | Bin 0 -> 9602 bytes .../assets/images/external/page-circle@2x.png | Bin 0 -> 20565 bytes ui/src/assets/images/favicon.png | Bin 0 -> 5576 bytes ui/src/assets/images/loader.gif | Bin 0 -> 16087 bytes ui/src/assets/images/logo-symbol-color.png | Bin 0 -> 875 bytes ui/src/assets/images/logo-symbol-color@2x.png | Bin 0 -> 1693 bytes ui/src/assets/images/logo-text-color.png | Bin 0 -> 1168 bytes ui/src/assets/images/logo-text-color@2x.png | Bin 0 -> 2401 bytes ui/src/assets/images/logo-text-white.png | Bin 0 -> 1781 bytes ui/src/assets/images/logo-text-white@2x.png | Bin 0 -> 3908 bytes ui/src/assets/stylesheets/fonts.css | 5 + ui/src/assets/stylesheets/globals.css | 44 + ui/src/assets/stylesheets/icons.css | 431 + ui/src/assets/stylesheets/main.css | 11 + .../assets/stylesheets/react_sortablejs.css | 11 + ui/src/client/authLink.js | 20 + ui/src/client/cache.js | 5 + ui/src/client/debounceLink.js | 5 + ui/src/client/errorLink.js | 11 + ui/src/client/httpLink.js | 7 + ui/src/client/index.js | 31 + ui/src/client/methods.js | 16 + ui/src/client/stateLink.js | 11 + ui/src/components/ActionList.js | 63 + ui/src/components/AlertBox.js | 173 + ui/src/components/App.js | 106 + ui/src/components/AppContext.js | 3 + ui/src/components/AppLoader.js | 27 + ui/src/components/BaseModal.js | 78 + ui/src/components/BaseSlider.js | 67 + ui/src/components/ClientProvider.js | 52 + ui/src/components/Container.js | 20 + ui/src/components/FieldError.js | 63 + ui/src/components/FieldHint.js | 45 + ui/src/components/FontIcon.js | 33 + ui/src/components/ItemBar.js | 48 + ui/src/components/LoaderView.js | 40 + ui/src/components/Logo.js | 34 + ui/src/components/ScrollToTop.js | 26 + ui/src/components/Spacer.js | 18 + ui/src/components/buttons/CloseButton.js | 25 + ui/src/components/buttons/DragButton.js | 20 + ui/src/components/buttons/FilledButton.js | 161 + ui/src/components/decorators/withUniqueId.js | 38 + ui/src/components/external/FieldError.js | 61 + ui/src/components/external/Footer.js | 71 + ui/src/components/external/GridContainer.js | 31 + ui/src/components/external/GridItem.js | 29 + ui/src/components/external/Header.js | 72 + .../external/buttons/SimpleButton.js | 56 + .../components/external/inputs/TextInput.js | 69 + .../external/typography/FieldErrorText.js | 18 + .../external/typography/FooterLink.js | 43 + .../external/typography/FooterText.js | 18 + .../components/external/typography/Heading.js | 28 + .../components/external/typography/NavLink.js | 66 + .../external/typography/PageHeading.js | 28 + .../external/typography/PageLink.js | 21 + .../external/typography/PageList.js | 22 + .../external/typography/PageListItem.js | 36 + .../external/typography/PageListText.js | 18 + .../external/typography/PageSubHeading.js | 26 + .../external/typography/PageText.js | 26 + ui/src/components/external/typography/Text.js | 24 + .../external/typography/TextLink.js | 22 + .../components/external/typography/Title.js | 18 + .../components/external/typography/index.js | 15 + ui/src/components/inputs/TextInput.js | 262 + ui/src/components/inputs/UploadInput.js | 146 + ui/src/components/internal/AssetBox.js | 135 + ui/src/components/internal/AssetList.js | 32 + ui/src/components/internal/Badge.js | 46 + ui/src/components/internal/Box.js | 25 + ui/src/components/internal/Card.js | 23 + ui/src/components/internal/Code.js | 22 + ui/src/components/internal/ColorTile.js | 56 + ui/src/components/internal/Column.js | 55 + ui/src/components/internal/Container.js | 18 + ui/src/components/internal/Content.js | 36 + ui/src/components/internal/CopyToClipboard.js | 74 + ui/src/components/internal/DataTiles.js | 96 + ui/src/components/internal/Dialog.js | 93 + .../components/internal/DialogFormFooter.js | 24 + ui/src/components/internal/Divider.js | 26 + ui/src/components/internal/FieldGroup.js | 38 + ui/src/components/internal/FieldPrefix.js | 18 + ui/src/components/internal/FormFooter.js | 23 + ui/src/components/internal/Header.js | 235 + ui/src/components/internal/HeaderItem.js | 88 + .../components/internal/HeaderItemContent.js | 31 + ui/src/components/internal/HintBox.js | 43 + ui/src/components/internal/Loader.js | 77 + ui/src/components/internal/Modal.js | 41 + ui/src/components/internal/ProfilePicture.js | 117 + ui/src/components/internal/Row.js | 24 + ui/src/components/internal/Tag.js | 111 + ui/src/components/internal/Tooltip.js | 141 + .../components/internal/buttons/IconButton.js | 74 + .../components/internal/dataTable/BoxCell.js | 34 + .../components/internal/dataTable/Column.js | 47 + .../internal/dataTable/DefaultCellWrapper.js | 70 + ui/src/components/internal/dataTable/Row.js | 334 + ui/src/components/internal/dataTable/Table.js | 122 + .../internal/decorators/withConfirmation.js | 37 + .../internal/dialogs/AssetDialog.js | 17 + .../internal/dialogs/ConfirmationDialog.js | 49 + .../internal/dialogs/ImportProjectDialog.js | 16 + .../internal/dialogs/ProjectDialog.js | 16 + .../components/internal/dialogs/TeamDialog.js | 16 + .../internal/dialogs/TeamMemberDialog.js | 21 + .../internal/fields/ConditionalField.js | 42 + ui/src/components/internal/forms/AssetForm.js | 25 + .../internal/forms/ChangePasswordForm.js | 60 + .../internal/forms/ChangeProjectNameForm.js | 26 + .../internal/forms/ChangeTeamNameForm.js | 28 + .../forms/CreateTransferRequestForm.js | 43 + .../components/internal/forms/EntityForm.js | 58 + ui/src/components/internal/forms/FieldForm.js | 277 + .../internal/forms/ImportProjectForm.js | 26 + .../components/internal/forms/ProjectForm.js | 44 + .../components/internal/forms/RecordForm.js | 522 + ui/src/components/internal/forms/TeamForm.js | 50 + .../internal/forms/TeamMemberForm.js | 148 + .../internal/forms/UpdateProfileForm.js | 40 + .../forms/UpdateProfilePictureForm.js | 63 + .../internal/headerItems/ProjectHeaderItem.js | 56 + .../internal/headerItems/TeamHeaderItem.js | 55 + .../internal/headerItems/UserHeaderItem.js | 56 + .../internal/imageTile/ImageTile.js | 68 + .../internal/imageTile/ImageTiles.js | 23 + ui/src/components/internal/imageTile/index.js | 2 + .../internal/inputs/BaseSelectInput.js | 821 ++ .../internal/inputs/ButtonGroupInput.js | 156 + .../internal/inputs/CheckboxInput.js | 86 + .../internal/inputs/ColorPickerInput.js | 72 + .../internal/inputs/ImageDropInput.js | 264 + .../internal/inputs/MultiSelectInput.js | 173 + .../components/internal/inputs/RadioInput.js | 89 + .../internal/inputs/SingleSelectInput.js | 203 + .../components/internal/inputs/SwitchInput.js | 131 + ui/src/components/internal/menu/Menu.js | 48 + ui/src/components/internal/menu/MenuBody.js | 34 + .../components/internal/menu/MenuContainer.js | 97 + .../components/internal/menu/MenuDivider.js | 18 + ui/src/components/internal/menu/MenuFooter.js | 26 + .../components/internal/menu/MenuHeading.js | 19 + ui/src/components/internal/menu/MenuItem.js | 57 + ui/src/components/internal/menu/MenuLink.js | 58 + .../internal/menu/MenuSearchHeader.js | 64 + .../components/internal/menus/ProjectMenu.js | 99 + ui/src/components/internal/menus/TeamMenu.js | 95 + ui/src/components/internal/menus/UserMenu.js | 45 + .../internal/modals/PictureModal.js | 20 + .../components/internal/modals/RecordModal.js | 26 + .../internal/pageToolbar/PageToolbar.js | 274 + .../internal/pageToolbar/SortAndFilterMenu.js | 155 + ui/src/components/internal/panel/Panel.js | 27 + ui/src/components/internal/panel/PanelBody.js | 19 + .../internal/panel/PanelContainer.js | 17 + .../components/internal/panel/PanelDetails.js | 18 + .../components/internal/panel/PanelHeader.js | 36 + .../components/internal/panel/PanelHeading.js | 16 + .../internal/panel/PanelSubHeading.js | 16 + .../components/internal/panel/PanelTable.js | 147 + ui/src/components/internal/panel/index.js | 8 + .../components/internal/sidePane/SidePane.js | 39 + .../internal/sidePane/SidePaneBody.js | 24 + .../internal/sidePane/SidePaneFormFooter.js | 21 + .../internal/sidePane/SidePaneHeader.js | 41 + ui/src/components/internal/sidePane/index.js | 4 + .../internal/sidePanes/EntitySidePane.js | 27 + .../internal/sidePanes/FieldSidePane.js | 27 + .../internal/sidePanes/RecordSidePane.js | 27 + ui/src/components/internal/sidebar/Sidebar.js | 66 + .../internal/sidebar/SidebarBreadcrumb.js | 119 + .../internal/sidebar/SidebarItem.js | 98 + .../internal/sidebars/ProjectSidebar.js | 44 + .../internal/sidebars/TeamSidebar.js | 52 + .../internal/sidebars/UserSidebar.js | 38 + ui/src/components/internal/tab/Tab.js | 119 + ui/src/components/internal/tab/TabLink.js | 73 + ui/src/components/internal/tab/TabList.js | 27 + ui/src/components/internal/tab/index.js | 7 + .../internal/typography/BackLink.js | 29 + .../internal/typography/CellContent.js | 18 + .../internal/typography/CellLabel.js | 25 + .../internal/typography/CellText.js | 18 + .../internal/typography/CellTitle.js | 18 + .../internal/typography/Description.js | 26 + .../internal/typography/DialogDescription.js | 24 + .../internal/typography/DialogTitle.js | 18 + .../internal/typography/HeaderItemText.js | 28 + .../internal/typography/HeaderItemTitle.js | 18 + ui/src/components/internal/typography/Hint.js | 45 + .../internal/typography/LoaderText.js | 24 + .../internal/typography/LoaderTitle.js | 25 + .../internal/typography/PageSubTitle.js | 25 + .../internal/typography/PageTitle.js | 27 + .../internal/typography/PanelText.js | 34 + .../internal/typography/RadioInputOption.js | 105 + .../internal/typography/SidePaneHint.js | 24 + .../internal/typography/SidePaneSubtitle.js | 18 + .../internal/typography/SidePaneTitle.js | 24 + .../typography/SidebarBreadcrumbText.js | 26 + .../typography/SidebarBreadcrumbTitle.js | 18 + .../internal/typography/SidebarItemText.js | 18 + .../internal/typography/SubTitle.js | 25 + ui/src/components/internal/typography/Text.js | 18 + .../internal/typography/TextLink.js | 47 + .../components/internal/typography/Title.js | 18 + .../components/internal/typography/index.js | 27 + ui/src/components/internal/views/EmptyView.js | 32 + .../components/internal/views/EmptyWrapper.js | 64 + ui/src/components/layouts/ExternalLayout.js | 95 + ui/src/components/layouts/InternalLayout.js | 36 + ui/src/components/layouts/OnboardingLayout.js | 37 + ui/src/components/onboarding/Body.js | 22 + ui/src/components/onboarding/Card.js | 41 + ui/src/components/onboarding/CardFootnote.js | 38 + ui/src/components/onboarding/CardWrapper.js | 55 + ui/src/components/onboarding/Content.js | 48 + ui/src/components/onboarding/FormFooter.js | 23 + ui/src/components/onboarding/Header.js | 56 + .../onboarding/forms/ConfirmUserForm.js | 31 + .../onboarding/forms/ForgotPasswordForm.js | 27 + .../components/onboarding/forms/LoginForm.js | 28 + .../onboarding/forms/ResetPasswordForm.js | 29 + .../components/onboarding/forms/SignupForm.js | 27 + .../onboarding/typography/CardHeading.js | 26 + .../onboarding/typography/CardText.js | 26 + .../onboarding/typography/Greeting.js | 18 + .../onboarding/typography/Heading.js | 13 + .../onboarding/typography/NavLink.js | 34 + .../components/onboarding/typography/Text.js | 18 + .../onboarding/typography/TextLink.js | 37 + .../components/onboarding/typography/index.js | 7 + ui/src/components/pages/AssetsPage.js | 195 + ui/src/components/pages/ConfirmPage.js | 324 + ui/src/components/pages/DashboardPage.js | 22 + ui/src/components/pages/EntitiesPage.js | 240 + ui/src/components/pages/EntityPage.js | 85 + ui/src/components/pages/FieldsPage.js | 279 + ui/src/components/pages/ForgotPasswordPage.js | 69 + ui/src/components/pages/HomePage.js | 80 + ui/src/components/pages/LoginPage.js | 70 + ui/src/components/pages/PrivacyPolicyPage.js | 637 ++ ui/src/components/pages/ProjectPage.js | 69 + .../components/pages/ProjectSettingsPage.js | 321 + ui/src/components/pages/ProjectsPage.js | 105 + ui/src/components/pages/RecordsPage.js | 387 + ui/src/components/pages/ResetPasswordPage.js | 85 + ui/src/components/pages/SignupPage.js | 202 + ui/src/components/pages/TeamBillingPage.js | 22 + ui/src/components/pages/TeamMembersPage.js | 290 + ui/src/components/pages/TeamPage.js | 63 + ui/src/components/pages/TeamSettingsPage.js | 241 + ui/src/components/pages/TeamsPage.js | 100 + ui/src/components/pages/TermsOfServicePage.js | 354 + .../components/pages/TransferRequestPage.js | 60 + .../components/pages/UserNotificationsPage.js | 22 + ui/src/components/pages/UserPage.js | 20 + ui/src/components/pages/UserProfilePage.js | 130 + ui/src/components/pages/UserSettingsPage.js | 50 + ui/src/components/routers/ExternalRouter.js | 31 + ui/src/components/routers/InternalRouter.js | 27 + ui/src/components/routers/OnboardingRouter.js | 35 + ui/src/components/routes/ExternalRoute.js | 17 + .../components/routes/InternalFluidRoute.js | 18 + ui/src/components/routes/InternalRoute.js | 17 + ui/src/components/routes/OnboardingRoute.js | 17 + ui/src/components/routes/ProtectedRoute.js | 67 + ui/src/components/typography/BaseText.js | 83 + ui/src/components/typography/IconLink.js | 61 + ui/src/components/typography/Paragraph.js | 13 + ui/src/components/typography/Text.js | 13 + ui/src/components/typography/TextLink.js | 40 + ui/src/components/typography/index.js | 3 + ui/src/constants/settings.js | 3 + ui/src/index.js | 38 + ui/src/lib/cleanProps.js | 19 + ui/src/lib/data.js | 334 + ui/src/lib/dateTime.js | 7 + ui/src/lib/errorParser.js | 87 + ui/src/lib/filesize.js | 3 + ui/src/lib/formMutators.js | 14 + ui/src/lib/getRandomNumber.js | 3 + ui/src/lib/hooks/useModal.js | 15 + ui/src/lib/hooks/useSidePane.js | 15 + ui/src/lib/isPromise.js | 3 + ui/src/lib/isRetina.js | 15 + ui/src/lib/lazy.js | 23 + ui/src/lib/objectToList.js | 3 + ui/src/lib/resolveImage.js | 19 + ui/src/lib/resolveImageUrl.js | 9 + ui/src/lib/toSentence.js | 11 + ui/src/lib/toString.js | 10 + ui/src/lib/validators.js | 11 + ui/src/models/Asset.js | 24 + ui/src/models/BaseModel.js | 69 + ui/src/models/Entity.js | 20 + ui/src/models/Field.js | 48 + ui/src/models/KeyPair.js | 10 + ui/src/models/Project.js | 19 + ui/src/models/Record.js | 149 + ui/src/models/Restore.js | 15 + ui/src/models/Team.js | 23 + ui/src/models/TeamMembership.js | 36 + ui/src/models/User.js | 36 + ui/src/models/index.js | 9 + ui/src/mutations/alert.js | 21 + ui/src/mutations/referrer.js | 9 + ui/src/mutations/session.js | 9 + ui/src/queries/alert.js | 15 + ui/src/queries/referrer.js | 11 + ui/src/queries/session.js | 11 + ui/src/resolvers/alert.js | 62 + ui/src/resolvers/index.js | 9 + ui/src/resolvers/referrer.js | 30 + ui/src/resolvers/session.js | 30 + ui/src/styles/mixins.js | 138 + ui/src/styles/theme.js | 1097 ++ ui/src/template.ejs | 8 + ui/static.json | 13 + ui/webpack.config.js | 223 + ui/yarn.lock | 8869 +++++++++++++++++ 676 files changed, 38771 insertions(+), 3 deletions(-) create mode 100644 .gitignore create mode 100755 api/.env.example create mode 100755 api/.gitignore create mode 100755 api/.rubocop.yml create mode 100755 api/.ruby-gemset create mode 100755 api/.ruby-version create mode 100755 api/Gemfile create mode 100755 api/Gemfile.lock create mode 100755 api/Guardfile create mode 100755 api/Procfile create mode 100755 api/README.md create mode 100755 api/Rakefile create mode 100755 api/app/assets/config/manifest.js create mode 100755 api/app/assets/images/.keep create mode 100755 api/app/assets/javascripts/application.js create mode 100755 api/app/assets/javascripts/cable.js create mode 100755 api/app/assets/javascripts/channels/.keep create mode 100755 api/app/assets/stylesheets/application.css create mode 100755 api/app/channels/application_cable/channel.rb create mode 100755 api/app/channels/application_cable/connection.rb create mode 100755 api/app/controllers/application_controller.rb create mode 100644 api/app/controllers/base_current_project_controller.rb create mode 100644 api/app/controllers/base_current_user_controller.rb create mode 100755 api/app/controllers/concerns/api_error_handler.rb create mode 100755 api/app/controllers/graphql_controller.rb create mode 100644 api/app/controllers/v1/content_controller.rb create mode 100644 api/app/controllers/v1/entities_controller.rb create mode 100755 api/app/graphql/clay_api_schema.rb create mode 100755 api/app/graphql/functions/application_function.rb create mode 100755 api/app/graphql/graphql_policy.rb create mode 100755 api/app/graphql/mutations/.keep create mode 100644 api/app/graphql/mutators/accept_transfer_request_mutator.rb create mode 100755 api/app/graphql/mutators/application_mutator.rb create mode 100644 api/app/graphql/mutators/cancel_transfer_request_mutator.rb create mode 100644 api/app/graphql/mutators/clone_record_mutator.rb create mode 100644 api/app/graphql/mutators/create_asset_mutator.rb create mode 100644 api/app/graphql/mutators/create_entity_mutator.rb create mode 100644 api/app/graphql/mutators/create_field_mutator.rb create mode 100644 api/app/graphql/mutators/create_key_pair_mutator.rb create mode 100644 api/app/graphql/mutators/create_project_mutator.rb create mode 100644 api/app/graphql/mutators/create_record_mutator.rb create mode 100644 api/app/graphql/mutators/create_resource_mutator.rb create mode 100644 api/app/graphql/mutators/create_team_membership_mutator.rb create mode 100644 api/app/graphql/mutators/create_team_mutator.rb create mode 100644 api/app/graphql/mutators/create_transfer_request_mutator.rb create mode 100644 api/app/graphql/mutators/destroy_asset_mutator.rb create mode 100644 api/app/graphql/mutators/destroy_entity_mutator.rb create mode 100644 api/app/graphql/mutators/destroy_field_mutator.rb create mode 100644 api/app/graphql/mutators/destroy_record_mutator.rb create mode 100644 api/app/graphql/mutators/destroy_resource_mutator.rb create mode 100644 api/app/graphql/mutators/destroy_team_membership_mutator.rb create mode 100644 api/app/graphql/mutators/export_project_mutator.rb create mode 100644 api/app/graphql/mutators/import_project_mutator.rb create mode 100644 api/app/graphql/mutators/reject_transfer_request_mutator.rb create mode 100644 api/app/graphql/mutators/revoke_key_pair_mutator.rb create mode 100644 api/app/graphql/mutators/sort_fields_mutator.rb create mode 100644 api/app/graphql/mutators/sso_callback_mutator.rb create mode 100644 api/app/graphql/mutators/sso_login_mutator.rb create mode 100644 api/app/graphql/mutators/sso_logout_mutator.rb create mode 100644 api/app/graphql/mutators/update_asset_mutator.rb create mode 100644 api/app/graphql/mutators/update_entity_mutator.rb create mode 100644 api/app/graphql/mutators/update_field_mutator.rb create mode 100644 api/app/graphql/mutators/update_profile_mutator.rb create mode 100644 api/app/graphql/mutators/update_project_mutator.rb create mode 100644 api/app/graphql/mutators/update_record_mutator.rb create mode 100644 api/app/graphql/mutators/update_resource_mutator.rb create mode 100644 api/app/graphql/mutators/update_team_membership_mutator.rb create mode 100644 api/app/graphql/mutators/update_team_mutator.rb create mode 100755 api/app/graphql/resolvers/application_resolver.rb create mode 100644 api/app/graphql/resolvers/assets_resolver.rb create mode 100755 api/app/graphql/resolvers/current_user_resolver.rb create mode 100644 api/app/graphql/resolvers/entities_resolver.rb create mode 100644 api/app/graphql/resolvers/entity_resolver.rb create mode 100644 api/app/graphql/resolvers/exports_resolver.rb create mode 100644 api/app/graphql/resolvers/fields_resolver.rb create mode 100644 api/app/graphql/resolvers/key_pairs_resolver.rb create mode 100644 api/app/graphql/resolvers/project_resolver.rb create mode 100644 api/app/graphql/resolvers/projects_resolver.rb create mode 100644 api/app/graphql/resolvers/record_resolver.rb create mode 100644 api/app/graphql/resolvers/records_resolver.rb create mode 100644 api/app/graphql/resolvers/referenced_entities_resolver.rb create mode 100644 api/app/graphql/resolvers/resources_resolver.rb create mode 100644 api/app/graphql/resolvers/restores_resolver.rb create mode 100644 api/app/graphql/resolvers/team_memberships_resolver.rb create mode 100644 api/app/graphql/resolvers/team_resolver.rb create mode 100644 api/app/graphql/resolvers/teams_resolver.rb create mode 100755 api/app/graphql/roots/mutation_type.rb create mode 100755 api/app/graphql/roots/query_type.rb create mode 100755 api/app/graphql/scalars/base_scalar_type.rb create mode 100644 api/app/graphql/scalars/file_type.rb create mode 100644 api/app/graphql/scalars/hash_type.rb create mode 100644 api/app/graphql/scalars/json_type.rb create mode 100755 api/app/graphql/types/application_type.rb create mode 100644 api/app/graphql/types/asset_type.rb create mode 100644 api/app/graphql/types/entity_type.rb create mode 100644 api/app/graphql/types/export_type.rb create mode 100644 api/app/graphql/types/field_type.rb create mode 100644 api/app/graphql/types/key_pair_type.rb create mode 100644 api/app/graphql/types/locale_type.rb create mode 100644 api/app/graphql/types/project_type.rb create mode 100644 api/app/graphql/types/property_type.rb create mode 100644 api/app/graphql/types/record_type.rb create mode 100644 api/app/graphql/types/relationship_type.rb create mode 100644 api/app/graphql/types/resource_type.rb create mode 100755 api/app/graphql/types/response_type.rb create mode 100644 api/app/graphql/types/restore_type.rb create mode 100644 api/app/graphql/types/team_membership_type.rb create mode 100644 api/app/graphql/types/team_type.rb create mode 100755 api/app/graphql/types/user_type.rb create mode 100755 api/app/helpers/application_helper.rb create mode 100644 api/app/interactors/accept_transfer_request.rb create mode 100644 api/app/interactors/authenticate_user.rb create mode 100644 api/app/interactors/cancel_transfer_request.rb create mode 100644 api/app/interactors/clone_record.rb create mode 100644 api/app/interactors/create_asset.rb create mode 100644 api/app/interactors/create_entity.rb create mode 100644 api/app/interactors/create_field.rb create mode 100644 api/app/interactors/create_key_pair.rb create mode 100644 api/app/interactors/create_project.rb create mode 100644 api/app/interactors/create_record.rb create mode 100644 api/app/interactors/create_resource.rb create mode 100644 api/app/interactors/create_team.rb create mode 100644 api/app/interactors/create_team_membership.rb create mode 100644 api/app/interactors/create_transfer_request.rb create mode 100644 api/app/interactors/destroy_asset.rb create mode 100644 api/app/interactors/destroy_entity.rb create mode 100644 api/app/interactors/destroy_field.rb create mode 100644 api/app/interactors/destroy_record.rb create mode 100644 api/app/interactors/destroy_resource.rb create mode 100644 api/app/interactors/destroy_team_membership.rb create mode 100644 api/app/interactors/export_project.rb create mode 100644 api/app/interactors/import_project.rb create mode 100644 api/app/interactors/perform_export.rb create mode 100644 api/app/interactors/perform_restore.rb create mode 100644 api/app/interactors/reject_transfer_request.rb create mode 100644 api/app/interactors/revoke_key_pair.rb create mode 100644 api/app/interactors/sort_fields.rb create mode 100644 api/app/interactors/sso_callback.rb create mode 100644 api/app/interactors/sso_login.rb create mode 100644 api/app/interactors/sso_logout.rb create mode 100644 api/app/interactors/update_asset.rb create mode 100644 api/app/interactors/update_entity.rb create mode 100644 api/app/interactors/update_field.rb create mode 100644 api/app/interactors/update_project.rb create mode 100644 api/app/interactors/update_record.rb create mode 100644 api/app/interactors/update_resource.rb create mode 100644 api/app/interactors/update_team.rb create mode 100644 api/app/interactors/update_team_membership.rb create mode 100644 api/app/interactors/update_user.rb create mode 100755 api/app/jobs/application_job.rb create mode 100755 api/app/mailers/application_mailer.rb create mode 100755 api/app/models/application_record.rb create mode 100644 api/app/models/asset.rb create mode 100644 api/app/models/auth_nonce.rb create mode 100644 api/app/models/auth_token.rb create mode 100644 api/app/models/concerns/nested_fetchable.rb create mode 100755 api/app/models/concerns/tokenable.rb create mode 100644 api/app/models/concerns/transferable.rb create mode 100644 api/app/models/concerns/uid.rb create mode 100644 api/app/models/entity.rb create mode 100644 api/app/models/export.rb create mode 100644 api/app/models/field.rb create mode 100644 api/app/models/key_pair.rb create mode 100644 api/app/models/locale.rb create mode 100644 api/app/models/project.rb create mode 100644 api/app/models/property.rb create mode 100644 api/app/models/record.rb create mode 100644 api/app/models/relationship.rb create mode 100644 api/app/models/resource.rb create mode 100644 api/app/models/restore.rb create mode 100644 api/app/models/team.rb create mode 100644 api/app/models/team_membership.rb create mode 100644 api/app/models/user.rb create mode 100755 api/app/policies/application_policy.rb create mode 100644 api/app/policies/asset_policy.rb create mode 100644 api/app/policies/base_member_policy.rb create mode 100644 api/app/policies/entity_policy.rb create mode 100644 api/app/policies/field_policy.rb create mode 100644 api/app/policies/key_pair_policy.rb create mode 100644 api/app/policies/project_policy.rb create mode 100644 api/app/policies/record_policy.rb create mode 100644 api/app/policies/resource_policy.rb create mode 100644 api/app/policies/team_membership_policy.rb create mode 100644 api/app/policies/team_policy.rb create mode 100644 api/app/publishers/base_publisher.rb create mode 100644 api/app/publishers/export_project_publisher.rb create mode 100644 api/app/publishers/import_project_publisher.rb create mode 100644 api/app/uploaders/asset_file_uploader.rb create mode 100644 api/app/uploaders/base_uploader.rb create mode 100644 api/app/uploaders/export_file_uploader.rb create mode 100644 api/app/uploaders/image_uploader.rb create mode 100644 api/app/uploaders/profile_picture_uploader.rb create mode 100644 api/app/uploaders/resource_file_uploader.rb create mode 100755 api/app/validators/email_format_validator.rb create mode 100755 api/app/views/layouts/application.html.erb create mode 100755 api/app/views/layouts/mailer.html.erb create mode 100755 api/app/views/layouts/mailer.text.erb create mode 100644 api/app/workers/export_project_worker.rb create mode 100644 api/app/workers/import_project_worker.rb create mode 100755 api/bin/bundle create mode 100755 api/bin/rails create mode 100755 api/bin/rake create mode 100755 api/bin/scripts/clay create mode 100755 api/bin/scripts/setup-db-dev create mode 100755 api/bin/scripts/start-app create mode 100755 api/bin/setup create mode 100755 api/bin/spring create mode 100755 api/bin/update create mode 100755 api/bin/yarn create mode 100755 api/config.ru create mode 100755 api/config/application.rb create mode 100755 api/config/boot.rb create mode 100755 api/config/cable.yml create mode 100644 api/config/credentials.yml.enc create mode 100755 api/config/database.yml.example create mode 100755 api/config/environment.rb create mode 100755 api/config/environments/development.rb create mode 100755 api/config/environments/production.rb create mode 100644 api/config/environments/staging.rb create mode 100755 api/config/environments/test.rb create mode 100755 api/config/initializers/00_require.rb create mode 100755 api/config/initializers/application_controller_renderer.rb create mode 100755 api/config/initializers/backtrace_silencers.rb create mode 100644 api/config/initializers/bullet.rb create mode 100755 api/config/initializers/content_security_policy.rb create mode 100755 api/config/initializers/cookies_serializer.rb create mode 100755 api/config/initializers/filter_parameter_logging.rb create mode 100755 api/config/initializers/graphql_rails_logger.rb create mode 100755 api/config/initializers/inflections.rb create mode 100755 api/config/initializers/mime_types.rb create mode 100644 api/config/initializers/raven.rb create mode 100644 api/config/initializers/shrine.rb create mode 100644 api/config/initializers/sneakers.rb create mode 100755 api/config/initializers/wrap_parameters.rb create mode 100755 api/config/locales/en.yml create mode 100755 api/config/puma.rb create mode 100755 api/config/routes.rb create mode 100755 api/config/spring.rb create mode 100755 api/config/storage.yml create mode 100755 api/db/schema.rb create mode 100755 api/db/seeds.rb create mode 100644 api/fixtures/assets/logo.png create mode 100644 api/lib/app_url.rb create mode 100644 api/lib/credentials.rb create mode 100644 api/lib/diff.rb create mode 100755 api/lib/exceptions.rb create mode 100644 api/lib/gb_logger.rb create mode 100644 api/lib/json_web_token.rb create mode 100755 api/lib/mail_address.rb create mode 100644 api/lib/rabbit_mq.rb create mode 100644 api/lib/record_mapper.rb create mode 100644 api/lib/tasks/project.rake create mode 100755 api/lib/token.rb create mode 100644 api/lib/uid_generator.rb create mode 100644 api/lib/url.rb create mode 100755 api/log/.keep create mode 100755 api/package.json create mode 100755 api/public/404.html create mode 100755 api/public/422.html create mode 100755 api/public/500.html create mode 100755 api/public/apple-touch-icon-precomposed.png create mode 100755 api/public/apple-touch-icon.png create mode 100755 api/public/favicon.ico create mode 100755 api/public/robots.txt create mode 100644 api/spec/factories/assets.rb create mode 100644 api/spec/factories/auth_nonces.rb create mode 100644 api/spec/factories/auth_tokens.rb create mode 100644 api/spec/factories/entities.rb create mode 100644 api/spec/factories/exports.rb create mode 100644 api/spec/factories/fields.rb create mode 100644 api/spec/factories/key_pairs.rb create mode 100644 api/spec/factories/projects.rb create mode 100644 api/spec/factories/records.rb create mode 100644 api/spec/factories/resources.rb create mode 100644 api/spec/factories/restores.rb create mode 100644 api/spec/factories/team_memberships.rb create mode 100644 api/spec/factories/teams.rb create mode 100755 api/spec/factories/users.rb create mode 100644 api/spec/interactors/accept_transfer_request_spec.rb create mode 100644 api/spec/interactors/authenticate_user_spec.rb create mode 100644 api/spec/interactors/cancel_transfer_request_spec.rb create mode 100644 api/spec/interactors/create_asset_spec.rb create mode 100644 api/spec/interactors/create_entity_spec.rb create mode 100644 api/spec/interactors/create_key_pair_spec.rb create mode 100644 api/spec/interactors/create_project_spec.rb create mode 100644 api/spec/interactors/create_team_membership_spec.rb create mode 100644 api/spec/interactors/create_team_spec.rb create mode 100644 api/spec/interactors/create_transfer_request_spec.rb create mode 100644 api/spec/interactors/destroy_asset_spec.rb create mode 100644 api/spec/interactors/destroy_entity_spec.rb create mode 100644 api/spec/interactors/destroy_team_membership_spec.rb create mode 100644 api/spec/interactors/reject_transfer_request_spec.rb create mode 100644 api/spec/interactors/revoke_key_pair_spec.rb create mode 100644 api/spec/interactors/sso_callback_spec.rb create mode 100644 api/spec/interactors/sso_login_spec.rb create mode 100644 api/spec/interactors/update_asset_spec.rb create mode 100644 api/spec/interactors/update_entity_spec.rb create mode 100644 api/spec/interactors/update_project_spec.rb create mode 100644 api/spec/interactors/update_team_membership_spec.rb create mode 100644 api/spec/interactors/update_team_spec.rb create mode 100644 api/spec/interactors/update_user_spec.rb create mode 100644 api/spec/lib/app_url_spec.rb create mode 100644 api/spec/models/asset_spec.rb create mode 100644 api/spec/models/auth_nonce_spec.rb create mode 100644 api/spec/models/auth_token_spec.rb create mode 100644 api/spec/models/entity_spec.rb create mode 100644 api/spec/models/export_spec.rb create mode 100644 api/spec/models/field_spec.rb create mode 100644 api/spec/models/key_pair_spec.rb create mode 100644 api/spec/models/locale_spec.rb create mode 100644 api/spec/models/project_spec.rb create mode 100644 api/spec/models/property_spec.rb create mode 100644 api/spec/models/record_spec.rb create mode 100644 api/spec/models/relationship_spec.rb create mode 100644 api/spec/models/resource_spec.rb create mode 100644 api/spec/models/restore_spec.rb create mode 100644 api/spec/models/team_membership_spec.rb create mode 100644 api/spec/models/team_spec.rb create mode 100644 api/spec/models/user_spec.rb create mode 100644 api/spec/policies/asset_policy_spec.rb create mode 100644 api/spec/policies/entity_policy_spec.rb create mode 100644 api/spec/policies/field_policy_spec.rb create mode 100644 api/spec/policies/key_pair_policy_spec.rb create mode 100644 api/spec/policies/project_policy_spec.rb create mode 100644 api/spec/policies/record_policy_spec.rb create mode 100644 api/spec/policies/team_membership_policy_spec.rb create mode 100644 api/spec/policies/team_policy_spec.rb create mode 100755 api/spec/rails_helper.rb create mode 100755 api/spec/spec_helper.rb create mode 100644 api/spec/support/concerns/transferable.rb create mode 100755 api/spec/support/geocoder_helper.rb create mode 100644 api/spec/support/policy.rb create mode 100755 api/tmp/.keep create mode 100755 api/vendor/.keep create mode 100644 ui/.babelrc create mode 100644 ui/.env.example create mode 100644 ui/.env.heroku create mode 100644 ui/.eslintignore create mode 100644 ui/.eslintrc create mode 100644 ui/.gitignore create mode 100644 ui/LICENSE.md create mode 100644 ui/README.md create mode 100644 ui/netlify.toml create mode 100644 ui/package.json create mode 100644 ui/src/Root.js create mode 100755 ui/src/assets/fonts/claycms-icons.woff create mode 100755 ui/src/assets/fonts/claycms-icons.woff2 create mode 100644 ui/src/assets/images/external/footer-background-mobile.svg create mode 100644 ui/src/assets/images/external/footer-background.svg create mode 100644 ui/src/assets/images/external/page-circle.png create mode 100644 ui/src/assets/images/external/page-circle@2x.png create mode 100644 ui/src/assets/images/favicon.png create mode 100644 ui/src/assets/images/loader.gif create mode 100644 ui/src/assets/images/logo-symbol-color.png create mode 100644 ui/src/assets/images/logo-symbol-color@2x.png create mode 100644 ui/src/assets/images/logo-text-color.png create mode 100644 ui/src/assets/images/logo-text-color@2x.png create mode 100644 ui/src/assets/images/logo-text-white.png create mode 100644 ui/src/assets/images/logo-text-white@2x.png create mode 100644 ui/src/assets/stylesheets/fonts.css create mode 100644 ui/src/assets/stylesheets/globals.css create mode 100644 ui/src/assets/stylesheets/icons.css create mode 100644 ui/src/assets/stylesheets/main.css create mode 100644 ui/src/assets/stylesheets/react_sortablejs.css create mode 100644 ui/src/client/authLink.js create mode 100644 ui/src/client/cache.js create mode 100644 ui/src/client/debounceLink.js create mode 100644 ui/src/client/errorLink.js create mode 100644 ui/src/client/httpLink.js create mode 100644 ui/src/client/index.js create mode 100644 ui/src/client/methods.js create mode 100644 ui/src/client/stateLink.js create mode 100644 ui/src/components/ActionList.js create mode 100644 ui/src/components/AlertBox.js create mode 100644 ui/src/components/App.js create mode 100644 ui/src/components/AppContext.js create mode 100644 ui/src/components/AppLoader.js create mode 100644 ui/src/components/BaseModal.js create mode 100644 ui/src/components/BaseSlider.js create mode 100644 ui/src/components/ClientProvider.js create mode 100644 ui/src/components/Container.js create mode 100644 ui/src/components/FieldError.js create mode 100644 ui/src/components/FieldHint.js create mode 100644 ui/src/components/FontIcon.js create mode 100644 ui/src/components/ItemBar.js create mode 100644 ui/src/components/LoaderView.js create mode 100644 ui/src/components/Logo.js create mode 100644 ui/src/components/ScrollToTop.js create mode 100644 ui/src/components/Spacer.js create mode 100644 ui/src/components/buttons/CloseButton.js create mode 100644 ui/src/components/buttons/DragButton.js create mode 100644 ui/src/components/buttons/FilledButton.js create mode 100644 ui/src/components/decorators/withUniqueId.js create mode 100644 ui/src/components/external/FieldError.js create mode 100644 ui/src/components/external/Footer.js create mode 100644 ui/src/components/external/GridContainer.js create mode 100644 ui/src/components/external/GridItem.js create mode 100644 ui/src/components/external/Header.js create mode 100644 ui/src/components/external/buttons/SimpleButton.js create mode 100644 ui/src/components/external/inputs/TextInput.js create mode 100644 ui/src/components/external/typography/FieldErrorText.js create mode 100644 ui/src/components/external/typography/FooterLink.js create mode 100644 ui/src/components/external/typography/FooterText.js create mode 100644 ui/src/components/external/typography/Heading.js create mode 100644 ui/src/components/external/typography/NavLink.js create mode 100644 ui/src/components/external/typography/PageHeading.js create mode 100644 ui/src/components/external/typography/PageLink.js create mode 100644 ui/src/components/external/typography/PageList.js create mode 100644 ui/src/components/external/typography/PageListItem.js create mode 100644 ui/src/components/external/typography/PageListText.js create mode 100644 ui/src/components/external/typography/PageSubHeading.js create mode 100644 ui/src/components/external/typography/PageText.js create mode 100644 ui/src/components/external/typography/Text.js create mode 100644 ui/src/components/external/typography/TextLink.js create mode 100644 ui/src/components/external/typography/Title.js create mode 100644 ui/src/components/external/typography/index.js create mode 100644 ui/src/components/inputs/TextInput.js create mode 100644 ui/src/components/inputs/UploadInput.js create mode 100644 ui/src/components/internal/AssetBox.js create mode 100644 ui/src/components/internal/AssetList.js create mode 100644 ui/src/components/internal/Badge.js create mode 100644 ui/src/components/internal/Box.js create mode 100644 ui/src/components/internal/Card.js create mode 100644 ui/src/components/internal/Code.js create mode 100644 ui/src/components/internal/ColorTile.js create mode 100644 ui/src/components/internal/Column.js create mode 100644 ui/src/components/internal/Container.js create mode 100644 ui/src/components/internal/Content.js create mode 100644 ui/src/components/internal/CopyToClipboard.js create mode 100644 ui/src/components/internal/DataTiles.js create mode 100644 ui/src/components/internal/Dialog.js create mode 100644 ui/src/components/internal/DialogFormFooter.js create mode 100644 ui/src/components/internal/Divider.js create mode 100644 ui/src/components/internal/FieldGroup.js create mode 100644 ui/src/components/internal/FieldPrefix.js create mode 100644 ui/src/components/internal/FormFooter.js create mode 100644 ui/src/components/internal/Header.js create mode 100644 ui/src/components/internal/HeaderItem.js create mode 100644 ui/src/components/internal/HeaderItemContent.js create mode 100644 ui/src/components/internal/HintBox.js create mode 100644 ui/src/components/internal/Loader.js create mode 100644 ui/src/components/internal/Modal.js create mode 100644 ui/src/components/internal/ProfilePicture.js create mode 100644 ui/src/components/internal/Row.js create mode 100644 ui/src/components/internal/Tag.js create mode 100644 ui/src/components/internal/Tooltip.js create mode 100644 ui/src/components/internal/buttons/IconButton.js create mode 100644 ui/src/components/internal/dataTable/BoxCell.js create mode 100644 ui/src/components/internal/dataTable/Column.js create mode 100644 ui/src/components/internal/dataTable/DefaultCellWrapper.js create mode 100644 ui/src/components/internal/dataTable/Row.js create mode 100644 ui/src/components/internal/dataTable/Table.js create mode 100644 ui/src/components/internal/decorators/withConfirmation.js create mode 100644 ui/src/components/internal/dialogs/AssetDialog.js create mode 100644 ui/src/components/internal/dialogs/ConfirmationDialog.js create mode 100644 ui/src/components/internal/dialogs/ImportProjectDialog.js create mode 100644 ui/src/components/internal/dialogs/ProjectDialog.js create mode 100644 ui/src/components/internal/dialogs/TeamDialog.js create mode 100644 ui/src/components/internal/dialogs/TeamMemberDialog.js create mode 100644 ui/src/components/internal/fields/ConditionalField.js create mode 100644 ui/src/components/internal/forms/AssetForm.js create mode 100644 ui/src/components/internal/forms/ChangePasswordForm.js create mode 100644 ui/src/components/internal/forms/ChangeProjectNameForm.js create mode 100644 ui/src/components/internal/forms/ChangeTeamNameForm.js create mode 100644 ui/src/components/internal/forms/CreateTransferRequestForm.js create mode 100644 ui/src/components/internal/forms/EntityForm.js create mode 100644 ui/src/components/internal/forms/FieldForm.js create mode 100644 ui/src/components/internal/forms/ImportProjectForm.js create mode 100644 ui/src/components/internal/forms/ProjectForm.js create mode 100644 ui/src/components/internal/forms/RecordForm.js create mode 100644 ui/src/components/internal/forms/TeamForm.js create mode 100644 ui/src/components/internal/forms/TeamMemberForm.js create mode 100644 ui/src/components/internal/forms/UpdateProfileForm.js create mode 100644 ui/src/components/internal/forms/UpdateProfilePictureForm.js create mode 100644 ui/src/components/internal/headerItems/ProjectHeaderItem.js create mode 100644 ui/src/components/internal/headerItems/TeamHeaderItem.js create mode 100644 ui/src/components/internal/headerItems/UserHeaderItem.js create mode 100644 ui/src/components/internal/imageTile/ImageTile.js create mode 100644 ui/src/components/internal/imageTile/ImageTiles.js create mode 100644 ui/src/components/internal/imageTile/index.js create mode 100644 ui/src/components/internal/inputs/BaseSelectInput.js create mode 100644 ui/src/components/internal/inputs/ButtonGroupInput.js create mode 100644 ui/src/components/internal/inputs/CheckboxInput.js create mode 100644 ui/src/components/internal/inputs/ColorPickerInput.js create mode 100644 ui/src/components/internal/inputs/ImageDropInput.js create mode 100644 ui/src/components/internal/inputs/MultiSelectInput.js create mode 100644 ui/src/components/internal/inputs/RadioInput.js create mode 100644 ui/src/components/internal/inputs/SingleSelectInput.js create mode 100644 ui/src/components/internal/inputs/SwitchInput.js create mode 100644 ui/src/components/internal/menu/Menu.js create mode 100644 ui/src/components/internal/menu/MenuBody.js create mode 100644 ui/src/components/internal/menu/MenuContainer.js create mode 100644 ui/src/components/internal/menu/MenuDivider.js create mode 100644 ui/src/components/internal/menu/MenuFooter.js create mode 100644 ui/src/components/internal/menu/MenuHeading.js create mode 100644 ui/src/components/internal/menu/MenuItem.js create mode 100644 ui/src/components/internal/menu/MenuLink.js create mode 100644 ui/src/components/internal/menu/MenuSearchHeader.js create mode 100644 ui/src/components/internal/menus/ProjectMenu.js create mode 100644 ui/src/components/internal/menus/TeamMenu.js create mode 100644 ui/src/components/internal/menus/UserMenu.js create mode 100644 ui/src/components/internal/modals/PictureModal.js create mode 100644 ui/src/components/internal/modals/RecordModal.js create mode 100644 ui/src/components/internal/pageToolbar/PageToolbar.js create mode 100644 ui/src/components/internal/pageToolbar/SortAndFilterMenu.js create mode 100644 ui/src/components/internal/panel/Panel.js create mode 100644 ui/src/components/internal/panel/PanelBody.js create mode 100644 ui/src/components/internal/panel/PanelContainer.js create mode 100644 ui/src/components/internal/panel/PanelDetails.js create mode 100644 ui/src/components/internal/panel/PanelHeader.js create mode 100644 ui/src/components/internal/panel/PanelHeading.js create mode 100644 ui/src/components/internal/panel/PanelSubHeading.js create mode 100644 ui/src/components/internal/panel/PanelTable.js create mode 100644 ui/src/components/internal/panel/index.js create mode 100644 ui/src/components/internal/sidePane/SidePane.js create mode 100644 ui/src/components/internal/sidePane/SidePaneBody.js create mode 100644 ui/src/components/internal/sidePane/SidePaneFormFooter.js create mode 100644 ui/src/components/internal/sidePane/SidePaneHeader.js create mode 100644 ui/src/components/internal/sidePane/index.js create mode 100644 ui/src/components/internal/sidePanes/EntitySidePane.js create mode 100644 ui/src/components/internal/sidePanes/FieldSidePane.js create mode 100644 ui/src/components/internal/sidePanes/RecordSidePane.js create mode 100644 ui/src/components/internal/sidebar/Sidebar.js create mode 100644 ui/src/components/internal/sidebar/SidebarBreadcrumb.js create mode 100644 ui/src/components/internal/sidebar/SidebarItem.js create mode 100644 ui/src/components/internal/sidebars/ProjectSidebar.js create mode 100644 ui/src/components/internal/sidebars/TeamSidebar.js create mode 100644 ui/src/components/internal/sidebars/UserSidebar.js create mode 100644 ui/src/components/internal/tab/Tab.js create mode 100644 ui/src/components/internal/tab/TabLink.js create mode 100644 ui/src/components/internal/tab/TabList.js create mode 100644 ui/src/components/internal/tab/index.js create mode 100644 ui/src/components/internal/typography/BackLink.js create mode 100644 ui/src/components/internal/typography/CellContent.js create mode 100644 ui/src/components/internal/typography/CellLabel.js create mode 100644 ui/src/components/internal/typography/CellText.js create mode 100644 ui/src/components/internal/typography/CellTitle.js create mode 100644 ui/src/components/internal/typography/Description.js create mode 100644 ui/src/components/internal/typography/DialogDescription.js create mode 100644 ui/src/components/internal/typography/DialogTitle.js create mode 100644 ui/src/components/internal/typography/HeaderItemText.js create mode 100644 ui/src/components/internal/typography/HeaderItemTitle.js create mode 100644 ui/src/components/internal/typography/Hint.js create mode 100644 ui/src/components/internal/typography/LoaderText.js create mode 100644 ui/src/components/internal/typography/LoaderTitle.js create mode 100644 ui/src/components/internal/typography/PageSubTitle.js create mode 100644 ui/src/components/internal/typography/PageTitle.js create mode 100644 ui/src/components/internal/typography/PanelText.js create mode 100644 ui/src/components/internal/typography/RadioInputOption.js create mode 100644 ui/src/components/internal/typography/SidePaneHint.js create mode 100644 ui/src/components/internal/typography/SidePaneSubtitle.js create mode 100644 ui/src/components/internal/typography/SidePaneTitle.js create mode 100644 ui/src/components/internal/typography/SidebarBreadcrumbText.js create mode 100644 ui/src/components/internal/typography/SidebarBreadcrumbTitle.js create mode 100644 ui/src/components/internal/typography/SidebarItemText.js create mode 100644 ui/src/components/internal/typography/SubTitle.js create mode 100644 ui/src/components/internal/typography/Text.js create mode 100644 ui/src/components/internal/typography/TextLink.js create mode 100644 ui/src/components/internal/typography/Title.js create mode 100644 ui/src/components/internal/typography/index.js create mode 100644 ui/src/components/internal/views/EmptyView.js create mode 100644 ui/src/components/internal/views/EmptyWrapper.js create mode 100644 ui/src/components/layouts/ExternalLayout.js create mode 100644 ui/src/components/layouts/InternalLayout.js create mode 100644 ui/src/components/layouts/OnboardingLayout.js create mode 100644 ui/src/components/onboarding/Body.js create mode 100644 ui/src/components/onboarding/Card.js create mode 100644 ui/src/components/onboarding/CardFootnote.js create mode 100644 ui/src/components/onboarding/CardWrapper.js create mode 100644 ui/src/components/onboarding/Content.js create mode 100644 ui/src/components/onboarding/FormFooter.js create mode 100644 ui/src/components/onboarding/Header.js create mode 100644 ui/src/components/onboarding/forms/ConfirmUserForm.js create mode 100644 ui/src/components/onboarding/forms/ForgotPasswordForm.js create mode 100644 ui/src/components/onboarding/forms/LoginForm.js create mode 100644 ui/src/components/onboarding/forms/ResetPasswordForm.js create mode 100644 ui/src/components/onboarding/forms/SignupForm.js create mode 100644 ui/src/components/onboarding/typography/CardHeading.js create mode 100644 ui/src/components/onboarding/typography/CardText.js create mode 100644 ui/src/components/onboarding/typography/Greeting.js create mode 100644 ui/src/components/onboarding/typography/Heading.js create mode 100644 ui/src/components/onboarding/typography/NavLink.js create mode 100644 ui/src/components/onboarding/typography/Text.js create mode 100644 ui/src/components/onboarding/typography/TextLink.js create mode 100644 ui/src/components/onboarding/typography/index.js create mode 100644 ui/src/components/pages/AssetsPage.js create mode 100644 ui/src/components/pages/ConfirmPage.js create mode 100644 ui/src/components/pages/DashboardPage.js create mode 100644 ui/src/components/pages/EntitiesPage.js create mode 100644 ui/src/components/pages/EntityPage.js create mode 100644 ui/src/components/pages/FieldsPage.js create mode 100644 ui/src/components/pages/ForgotPasswordPage.js create mode 100644 ui/src/components/pages/HomePage.js create mode 100644 ui/src/components/pages/LoginPage.js create mode 100644 ui/src/components/pages/PrivacyPolicyPage.js create mode 100644 ui/src/components/pages/ProjectPage.js create mode 100644 ui/src/components/pages/ProjectSettingsPage.js create mode 100644 ui/src/components/pages/ProjectsPage.js create mode 100644 ui/src/components/pages/RecordsPage.js create mode 100644 ui/src/components/pages/ResetPasswordPage.js create mode 100644 ui/src/components/pages/SignupPage.js create mode 100644 ui/src/components/pages/TeamBillingPage.js create mode 100644 ui/src/components/pages/TeamMembersPage.js create mode 100644 ui/src/components/pages/TeamPage.js create mode 100644 ui/src/components/pages/TeamSettingsPage.js create mode 100644 ui/src/components/pages/TeamsPage.js create mode 100644 ui/src/components/pages/TermsOfServicePage.js create mode 100644 ui/src/components/pages/TransferRequestPage.js create mode 100644 ui/src/components/pages/UserNotificationsPage.js create mode 100644 ui/src/components/pages/UserPage.js create mode 100644 ui/src/components/pages/UserProfilePage.js create mode 100644 ui/src/components/pages/UserSettingsPage.js create mode 100644 ui/src/components/routers/ExternalRouter.js create mode 100644 ui/src/components/routers/InternalRouter.js create mode 100644 ui/src/components/routers/OnboardingRouter.js create mode 100644 ui/src/components/routes/ExternalRoute.js create mode 100644 ui/src/components/routes/InternalFluidRoute.js create mode 100644 ui/src/components/routes/InternalRoute.js create mode 100644 ui/src/components/routes/OnboardingRoute.js create mode 100644 ui/src/components/routes/ProtectedRoute.js create mode 100644 ui/src/components/typography/BaseText.js create mode 100644 ui/src/components/typography/IconLink.js create mode 100644 ui/src/components/typography/Paragraph.js create mode 100644 ui/src/components/typography/Text.js create mode 100644 ui/src/components/typography/TextLink.js create mode 100644 ui/src/components/typography/index.js create mode 100644 ui/src/constants/settings.js create mode 100644 ui/src/index.js create mode 100644 ui/src/lib/cleanProps.js create mode 100644 ui/src/lib/data.js create mode 100644 ui/src/lib/dateTime.js create mode 100644 ui/src/lib/errorParser.js create mode 100644 ui/src/lib/filesize.js create mode 100644 ui/src/lib/formMutators.js create mode 100644 ui/src/lib/getRandomNumber.js create mode 100644 ui/src/lib/hooks/useModal.js create mode 100644 ui/src/lib/hooks/useSidePane.js create mode 100644 ui/src/lib/isPromise.js create mode 100644 ui/src/lib/isRetina.js create mode 100644 ui/src/lib/lazy.js create mode 100644 ui/src/lib/objectToList.js create mode 100644 ui/src/lib/resolveImage.js create mode 100644 ui/src/lib/resolveImageUrl.js create mode 100644 ui/src/lib/toSentence.js create mode 100644 ui/src/lib/toString.js create mode 100644 ui/src/lib/validators.js create mode 100644 ui/src/models/Asset.js create mode 100644 ui/src/models/BaseModel.js create mode 100644 ui/src/models/Entity.js create mode 100644 ui/src/models/Field.js create mode 100644 ui/src/models/KeyPair.js create mode 100644 ui/src/models/Project.js create mode 100644 ui/src/models/Record.js create mode 100644 ui/src/models/Restore.js create mode 100644 ui/src/models/Team.js create mode 100644 ui/src/models/TeamMembership.js create mode 100644 ui/src/models/User.js create mode 100644 ui/src/models/index.js create mode 100644 ui/src/mutations/alert.js create mode 100644 ui/src/mutations/referrer.js create mode 100644 ui/src/mutations/session.js create mode 100644 ui/src/queries/alert.js create mode 100644 ui/src/queries/referrer.js create mode 100644 ui/src/queries/session.js create mode 100644 ui/src/resolvers/alert.js create mode 100644 ui/src/resolvers/index.js create mode 100644 ui/src/resolvers/referrer.js create mode 100644 ui/src/resolvers/session.js create mode 100644 ui/src/styles/mixins.js create mode 100644 ui/src/styles/theme.js create mode 100644 ui/src/template.ejs create mode 100644 ui/static.json create mode 100644 ui/webpack.config.js create mode 100644 ui/yarn.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6509c91 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk diff --git a/LICENSE b/LICENSE index e23e97e..4304181 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 KeepWorks +Copyright (c) 2020 KeepWorks Technologies Pvt Ltd Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 7e92398..330b0e2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ -# claycms -Clay CMS Monorepo +# Clay CMS + +Clay CMS Monorepo. diff --git a/api/.env.example b/api/.env.example new file mode 100755 index 0000000..8d41f35 --- /dev/null +++ b/api/.env.example @@ -0,0 +1,14 @@ +API_HOST=api.claycms-dev.io +API_PORT=8080 +ASSET_HOST=http://api.claycms-dev.io:8080 +APP_HOST=claycms-dev.io +RACK_ENV=development +RAILS_ENV=development +FORCE_SSL= +PORT=8080 +CLOUDAMQP_URL=amqp://guest:guest@localhost:5672 +RABBIT_HOST= +RABBIT_PORT= +CLAY_SSO_URL=http://localhost:3000/sso/clay +CLAY_SSO_SECRET=f1be8d630eb5d477310ed1882c5a04c0f6e7ac8e17e0d138a15fdea6495c6bf0 +HMAC_SECRET=4f78a814febed95543ccf83be99ad7baabd42e1db09ffd934e2a866a3e91b106 diff --git a/api/.gitignore b/api/.gitignore new file mode 100755 index 0000000..da58fcd --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,136 @@ +## Rails + +*.rbc +*.sassc +.sass-cache +capybara-*.html +.rspec +/db/*.sqlite3 +/db/*.sqlite3-journal +/public/system +/public/assets +/public/uploads +/public/exports +/coverage/ +/spec/reports/ +/spec/tmp +/spec/examples.txt +rerun.txt +pickle-email-*.html +dump.rdb + +/node_modules +/yarn-error.log + +## Environment normalisation: +/.bundle +/vendor/bundle + +# unless supporting rvm < 1.11.0 or doing something fancy, ignore this: +.rvmrc + +# if using bower-rails ignore default bower_components path bower.json files +/vendor/assets/bower_components +*.bowerrc +bower.json + +# Ignore pow environment settings +.powenv + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore uploaded files in development +/storage/* + +# Ignore master key for decrypting credentials and more. +/config/master.key + +# Ignore Byebug command history file. +.byebug_history + +## Documentation cache and generated files: +/.yardoc/ +/_yardoc/ +/doc/ +/rdoc/ + +# Developer-specific files - These have a corresponding *.example file as a template to quickly copy over +config/database.yml +.env + +## General + +# Git +**.orig + +# OS X +.DS_Store +.DS_Store? +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear on external disk +.Spotlight-V100 +.Trashes + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# Windows image file caches +Thumbs.db +ehthumbs.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Compiled source +*.com +*.class +*.dll +*.exe +*.o +*.so + +# Packages +# it's better to unpack these files and commit the raw source +# git has its own built in compression methods +*.7z +*.dmg +*.gz +*.iso +*.jar +*.rar +*.tar +*.zip + +# Logs and databases +*.log +*.sql +*.sqlite diff --git a/api/.rubocop.yml b/api/.rubocop.yml new file mode 100755 index 0000000..2c3d11d --- /dev/null +++ b/api/.rubocop.yml @@ -0,0 +1,97 @@ +require: rubocop-rails + +AllCops: + Exclude: + - 'db/schema.rb' + - 'bin/*' + +Bundler/OrderedGems: + Enabled: false + +Layout/FirstArrayElementIndentation: + EnforcedStyle: consistent + +Layout/FirstHashElementIndentation: + EnforcedStyle: consistent + +Layout/MultilineMethodCallIndentation: + EnforcedStyle: indented + +Lint/UnusedBlockArgument: + Enabled: false + +Metrics/AbcSize: + Enabled: false + +Metrics/BlockLength: + Enabled: false + +Layout/LineLength: + Enabled: false + +Lint/NonDeterministicRequireOrder: + Enabled: false + +Lint/RaiseException: + Enabled: true + +Lint/StructNewOverride: + Enabled: true + +Metrics/MethodLength: + Enabled: false + +Rails: + Enabled: true + +Rails/Delegate: + Enabled: false + +Rails/FilePath: + EnforcedStyle: arguments + +Rails/HasAndBelongsToMany: + Enabled: false + +Rails/ReversibleMigration: + Enabled: false + +Rails/SkipsModelValidations: + Enabled: false + +Rails/UniqueValidationWithoutIndex: + Enabled: false + +Rails/UnknownEnv: + Environments: + - development + - test + - staging + - production + +Style/Documentation: + Enabled: false + +Style/FrozenStringLiteralComment: # This cop is designed to help upgrade to Ruby 3.0 + Enabled: false + +Style/Lambda: + EnforcedStyle: literal + +Style/NegatedIf: + Enabled: false + +Style/NumericPredicate: + EnforcedStyle: comparison + +Style/SymbolArray: + EnforcedStyle: brackets + +Style/HashEachMethods: + Enabled: true + +Style/HashTransformKeys: + Enabled: true + +Style/HashTransformValues: + Enabled: true diff --git a/api/.ruby-gemset b/api/.ruby-gemset new file mode 100755 index 0000000..27db7eb --- /dev/null +++ b/api/.ruby-gemset @@ -0,0 +1 @@ +claycms-api diff --git a/api/.ruby-version b/api/.ruby-version new file mode 100755 index 0000000..cb1d89f --- /dev/null +++ b/api/.ruby-version @@ -0,0 +1 @@ +ruby-2.5.0 \ No newline at end of file diff --git a/api/Gemfile b/api/Gemfile new file mode 100755 index 0000000..7fa5770 --- /dev/null +++ b/api/Gemfile @@ -0,0 +1,103 @@ +source 'https://rubygems.org' +git_source(:github) { |repo| "https://github.com/#{repo}.git" } + +ruby '2.5.0' + +# Stack +gem 'rails', '~> 5.2.0.rc1' +gem 'pg' +gem 'mysql2', '~>0.4.4' +gem 'puma', '~> 3.11' +gem 'foreman' +gem 'rack-cors' +gem 'rack-canonical-host' + +# Auth +gem 'bcrypt', '~> 3.1.7' +gem 'pundit' +gem 'jwt' + +# ActiveRecord +gem 'closure_tree' +gem 'activerecord-import' +gem 'amoeba' + +# Logging +gem 'ruby-kafka' +gem 'rails_semantic_logger' + +# Error Logging +gem 'sentry-raven' + +# Performance +gem 'scout_apm' + +# Services +gem 'interactor-rails', '~> 2.0' + +# GraphQL +gem 'graphql', '~> 1.8.0.pre6' +gem 'graphql-sugar', github: 'keepworks/graphql-sugar', branch: 'support-1.8-datetime' +gem 'graphql-guard' +gem 'apollo_upload_server', '2.0.0.beta.1' + +# Uploads +gem 'shrine', '~> 3.2', '>= 3.2.1' +gem 'image_processing' +gem 'aws-sdk-s3', '~> 1' + +# Background Jobs +gem 'bunny' +gem 'sneakers' + +# Misc +gem 'geocoder' +gem 'mail' +gem 'device_detector' + +# Reduces boot times through caching; required in config/boot.rb +gem 'bootsnap', '>= 1.1.0', require: false + +group :development do + gem 'bullet' + gem 'graphiql-rails' + gem 'graphql-rails_logger' + gem 'listen', '>= 3.0.5', '< 3.2' + gem 'spring' + gem 'spring-watcher-listen', '~> 2.0.0' +end + +group :test do + gem 'simplecov', require: false + gem 'shoulda-matchers', github: 'thoughtbot/shoulda-matchers' + gem 'pundit-matchers', '~> 1.6.0' + gem 'timecop' + gem 'webmock' + gem 'climate_control' +end + +group :development, :test do + gem 'awesome_print' + gem 'rspec-rails' + gem 'factory_bot_rails' + + gem 'rubocop', '~> 0.81.0', require: false + gem 'rubocop-rails' + + gem 'guard' + gem 'guard-brakeman', require: false + gem 'guard-bundler', require: false + gem 'guard-foreman' + gem 'guard-rspec' + gem 'guard-rubocop' + gem 'terminal-notifier-guard', require: false # Shows test alerts in OS X 10.8 Notification Center + + gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] +end + +group :development, :test, :staging do + gem 'ffaker' +end + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] diff --git a/api/Gemfile.lock b/api/Gemfile.lock new file mode 100755 index 0000000..b9302a2 --- /dev/null +++ b/api/Gemfile.lock @@ -0,0 +1,428 @@ +GIT + remote: https://github.com/keepworks/graphql-sugar.git + revision: 8d94ce61509457e3a7c574b66dcf692c31394387 + branch: support-1.8-datetime + specs: + graphql-sugar (0.1.6) + +GIT + remote: https://github.com/thoughtbot/shoulda-matchers.git + revision: cd96089a56b97cd11f7502826636895253eca27d + specs: + shoulda-matchers (3.1.2) + activesupport (>= 4.2.0) + +GEM + remote: https://rubygems.org/ + specs: + actioncable (5.2.0.rc1) + actionpack (= 5.2.0.rc1) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + actionmailer (5.2.0.rc1) + actionpack (= 5.2.0.rc1) + actionview (= 5.2.0.rc1) + activejob (= 5.2.0.rc1) + mail (~> 2.5, >= 2.5.4) + rails-dom-testing (~> 2.0) + actionpack (5.2.0.rc1) + actionview (= 5.2.0.rc1) + activesupport (= 5.2.0.rc1) + rack (~> 2.0) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.0.2) + actionview (5.2.0.rc1) + activesupport (= 5.2.0.rc1) + builder (~> 3.1) + erubi (~> 1.4) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.0.3) + activejob (5.2.0.rc1) + activesupport (= 5.2.0.rc1) + globalid (>= 0.3.6) + activemodel (5.2.0.rc1) + activesupport (= 5.2.0.rc1) + activerecord (5.2.0.rc1) + activemodel (= 5.2.0.rc1) + activesupport (= 5.2.0.rc1) + arel (>= 9.0) + activerecord-import (1.0.4) + activerecord (>= 3.2) + activestorage (5.2.0.rc1) + actionpack (= 5.2.0.rc1) + activerecord (= 5.2.0.rc1) + marcel (~> 0.3.1) + activesupport (5.2.0.rc1) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (~> 0.7) + minitest (~> 5.1) + tzinfo (~> 1.1) + addressable (2.7.0) + public_suffix (>= 2.0.2, < 5.0) + amoeba (3.1.0) + activerecord (>= 3.2.6) + amq-protocol (2.3.1) + apollo_upload_server (2.0.0.beta.1) + graphql (~> 1.7) + rails (>= 4.2) + arel (9.0.0) + ast (2.4.0) + awesome_print (1.8.0) + aws-eventstream (1.0.3) + aws-partitions (1.210.0) + aws-sdk-core (3.66.0) + aws-eventstream (~> 1.0, >= 1.0.2) + aws-partitions (~> 1.0) + aws-sigv4 (~> 1.1) + jmespath (~> 1.0) + aws-sdk-kms (1.24.0) + aws-sdk-core (~> 3, >= 3.61.1) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.48.0) + aws-sdk-core (~> 3, >= 3.61.1) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.1) + aws-sigv4 (1.1.0) + aws-eventstream (~> 1.0, >= 1.0.2) + bcrypt (3.1.11) + bootsnap (1.3.0) + msgpack (~> 1.0) + brakeman (4.1.1) + builder (3.2.3) + bullet (6.1.0) + activesupport (>= 3.0.0) + uniform_notifier (~> 1.11) + bunny (2.15.0) + amq-protocol (~> 2.3, >= 2.3.1) + byebug (10.0.0) + climate_control (0.2.0) + closure_tree (7.0.0) + activerecord (>= 4.2.10) + with_advisory_lock (>= 4.0.0) + coderay (1.1.2) + concurrent-ruby (1.0.5) + content_disposition (1.0.0) + crack (0.4.3) + safe_yaml (~> 1.0.0) + crass (1.0.3) + device_detector (1.0.4) + diff-lcs (1.3) + digest-crc (0.5.1) + docile (1.3.1) + down (5.1.1) + addressable (~> 2.5) + erubi (1.7.0) + factory_bot (4.8.2) + activesupport (>= 3.0.0) + factory_bot_rails (4.8.2) + factory_bot (~> 4.8.2) + railties (>= 3.0.0) + faraday (0.15.0) + multipart-post (>= 1.2, < 3) + ffaker (2.8.1) + ffi (1.12.2) + foreman (0.87.1) + formatador (0.2.5) + geocoder (1.4.7) + globalid (0.4.1) + activesupport (>= 4.2.0) + graphiql-rails (1.7.0) + railties + sprockets-rails + graphql (1.8.0.pre6) + graphql-guard (1.0.0) + graphql (>= 1.6.0, < 2) + graphql-rails_logger (1.1.0) + actionpack (~> 5.0) + activesupport (~> 5.0) + railties (~> 5.0) + rouge (~> 3.0) + guard (2.16.2) + formatador (>= 0.2.4) + listen (>= 2.7, < 4.0) + lumberjack (>= 1.0.12, < 2.0) + nenv (~> 0.1) + notiffany (~> 0.0) + pry (>= 0.9.12) + shellany (~> 0.0) + thor (>= 0.18.1) + guard-brakeman (0.8.3) + brakeman (>= 2.1.1) + guard (>= 2.0.0) + guard-bundler (2.1.0) + bundler (~> 1.0) + guard (~> 2.2) + guard-compat (~> 1.1) + guard-compat (1.2.1) + guard-foreman (0.0.4) + guard (~> 2.6) + spoon (~> 0.0, >= 0.0.4) + guard-rspec (4.7.3) + guard (~> 2.1) + guard-compat (~> 1.1) + rspec (>= 2.99.0, < 4.0) + guard-rubocop (1.3.0) + guard (~> 2.0) + rubocop (~> 0.20) + hashdiff (0.3.7) + i18n (0.9.5) + concurrent-ruby (~> 1.0) + image_processing (1.9.3) + mini_magick (>= 4.9.5, < 5) + ruby-vips (>= 2.0.13, < 3) + interactor (3.1.0) + interactor-rails (2.1.1) + interactor (~> 3.0) + rails (>= 4.2, < 5.2) + jaro_winkler (1.5.4) + jmespath (1.4.0) + json (1.8.6) + jwt (2.2.2) + listen (3.1.5) + rb-fsevent (~> 0.9, >= 0.9.4) + rb-inotify (~> 0.9, >= 0.9.7) + ruby_dep (~> 1.2) + loofah (2.1.1) + crass (~> 1.0.2) + nokogiri (>= 1.5.9) + lumberjack (1.2.4) + mail (2.7.0) + mini_mime (>= 0.1.1) + marcel (0.3.1) + mimemagic (~> 0.3.2) + method_source (1.0.0) + mimemagic (0.3.2) + mini_magick (4.9.5) + mini_mime (1.0.0) + mini_portile2 (2.3.0) + minitest (5.11.3) + msgpack (1.2.4) + multipart-post (2.0.0) + mysql2 (0.4.10) + nenv (0.3.0) + nio4r (2.2.0) + nokogiri (1.8.2) + mini_portile2 (~> 2.3.0) + notiffany (0.1.3) + nenv (~> 0.1) + shellany (~> 0.0) + parallel (1.19.1) + parser (2.7.0.5) + ast (~> 2.4.0) + pg (1.0.0) + pry (0.13.0) + coderay (~> 1.1) + method_source (~> 1.0) + public_suffix (4.0.5) + puma (3.11.2) + pundit (1.1.0) + activesupport (>= 3.0.0) + pundit-matchers (1.6.0) + rspec-rails (>= 3.0.0) + rack (2.2.2) + rack-canonical-host (0.2.3) + addressable (> 0, < 3) + rack (>= 1.0.0, < 3) + rack-cors (1.0.2) + rack-test (0.8.2) + rack (>= 1.0, < 3) + rails (5.2.0.rc1) + actioncable (= 5.2.0.rc1) + actionmailer (= 5.2.0.rc1) + actionpack (= 5.2.0.rc1) + actionview (= 5.2.0.rc1) + activejob (= 5.2.0.rc1) + activemodel (= 5.2.0.rc1) + activerecord (= 5.2.0.rc1) + activestorage (= 5.2.0.rc1) + activesupport (= 5.2.0.rc1) + bundler (>= 1.3.0) + railties (= 5.2.0.rc1) + sprockets-rails (>= 2.0.0) + rails-dom-testing (2.0.3) + activesupport (>= 4.2.0) + nokogiri (>= 1.6) + rails-html-sanitizer (1.0.3) + loofah (~> 2.0) + rails_semantic_logger (4.4.4) + rack + railties (>= 3.2) + semantic_logger (~> 4.4) + railties (5.2.0.rc1) + actionpack (= 5.2.0.rc1) + activesupport (= 5.2.0.rc1) + method_source + rake (>= 0.8.7) + thor (>= 0.18.1, < 2.0) + rainbow (3.0.0) + rake (12.3.0) + rb-fsevent (0.10.3) + rb-inotify (0.10.1) + ffi (~> 1.0) + rexml (3.2.4) + rouge (3.3.0) + rspec (3.7.0) + rspec-core (~> 3.7.0) + rspec-expectations (~> 3.7.0) + rspec-mocks (~> 3.7.0) + rspec-core (3.7.1) + rspec-support (~> 3.7.0) + rspec-expectations (3.7.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.7.0) + rspec-mocks (3.7.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.7.0) + rspec-rails (3.7.2) + actionpack (>= 3.0) + activesupport (>= 3.0) + railties (>= 3.0) + rspec-core (~> 3.7.0) + rspec-expectations (~> 3.7.0) + rspec-mocks (~> 3.7.0) + rspec-support (~> 3.7.0) + rspec-support (3.7.1) + rubocop (0.81.0) + jaro_winkler (~> 1.5.1) + parallel (~> 1.10) + parser (>= 2.7.0.1) + rainbow (>= 2.2.2, < 4.0) + rexml + ruby-progressbar (~> 1.7) + unicode-display_width (>= 1.4.0, < 2.0) + rubocop-rails (2.5.1) + activesupport + rack (>= 1.1) + rubocop (>= 0.72.0) + ruby-kafka (1.0.0) + digest-crc + ruby-progressbar (1.10.1) + ruby-vips (2.0.14) + ffi (~> 1.9) + ruby_dep (1.5.0) + safe_yaml (1.0.4) + scout_apm (2.6.6) + parser + semantic_logger (4.6.1) + concurrent-ruby (~> 1.0) + sentry-raven (2.7.3) + faraday (>= 0.7.6, < 1.0) + serverengine (2.1.1) + sigdump (~> 0.2.2) + shellany (0.0.1) + shrine (3.2.1) + content_disposition (~> 1.0) + down (~> 5.1) + sigdump (0.2.4) + simplecov (0.16.1) + docile (~> 1.1) + json (>= 1.8, < 3) + simplecov-html (~> 0.10.0) + simplecov-html (0.10.2) + sneakers (2.12.0) + bunny (~> 2.14) + concurrent-ruby (~> 1.0) + rake (~> 12.3) + serverengine (~> 2.1.0) + thor + spoon (0.0.6) + ffi + spring (2.0.2) + activesupport (>= 4.2) + spring-watcher-listen (2.0.1) + listen (>= 2.7, < 4.0) + spring (>= 1.2, < 3.0) + sprockets (3.7.2) + concurrent-ruby (~> 1.0) + rack (> 1, < 3) + sprockets-rails (3.2.1) + actionpack (>= 4.0) + activesupport (>= 4.0) + sprockets (>= 3.0.0) + terminal-notifier-guard (1.7.0) + thor (1.0.1) + thread_safe (0.3.6) + timecop (0.9.1) + tzinfo (1.2.5) + thread_safe (~> 0.1) + unicode-display_width (1.7.0) + uniform_notifier (1.13.0) + webmock (3.4.1) + addressable (>= 2.3.6) + crack (>= 0.3.2) + hashdiff + websocket-driver (0.7.0) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.3) + with_advisory_lock (4.6.0) + activerecord (>= 4.2) + +PLATFORMS + ruby + +DEPENDENCIES + activerecord-import + amoeba + apollo_upload_server (= 2.0.0.beta.1) + awesome_print + aws-sdk-s3 (~> 1) + bcrypt (~> 3.1.7) + bootsnap (>= 1.1.0) + bullet + bunny + byebug + climate_control + closure_tree + device_detector + factory_bot_rails + ffaker + foreman + geocoder + graphiql-rails + graphql (~> 1.8.0.pre6) + graphql-guard + graphql-rails_logger + graphql-sugar! + guard + guard-brakeman + guard-bundler + guard-foreman + guard-rspec + guard-rubocop + image_processing + interactor-rails (~> 2.0) + jwt + listen (>= 3.0.5, < 3.2) + mail + mysql2 (~> 0.4.4) + pg + puma (~> 3.11) + pundit + pundit-matchers (~> 1.6.0) + rack-canonical-host + rack-cors + rails (~> 5.2.0.rc1) + rails_semantic_logger + rspec-rails + rubocop (~> 0.81.0) + rubocop-rails + ruby-kafka + scout_apm + sentry-raven + shoulda-matchers! + shrine (~> 3.2, >= 3.2.1) + simplecov + sneakers + spring + spring-watcher-listen (~> 2.0.0) + terminal-notifier-guard + timecop + tzinfo-data + webmock + +RUBY VERSION + ruby 2.5.0p0 + +BUNDLED WITH + 1.17.3 diff --git a/api/Guardfile b/api/Guardfile new file mode 100755 index 0000000..5fbbe59 --- /dev/null +++ b/api/Guardfile @@ -0,0 +1,42 @@ +guard :bundler do + require 'guard/bundler' + require 'guard/bundler/verify' + helper = Guard::Bundler::Verify.new + + files = ['Gemfile'] + files += Dir['*.gemspec'] if files.any? { |f| helper.uses_gemspec?(f) } + + # Assume files are symlinked from somewhere + files.each { |file| watch(helper.real_path(file)) } +end + +guard :foreman, concurrency: 'web=1,worker=1,release=0' do + watch(%r{^lib\/.+\.rb$}) + watch(%r{^config\/*}) +end + +guard :rubocop, cli: ['--display-cop-names'] do + watch(/.+\.rb$/) + watch(%r{(?:.+/)?\.rubocop\.yml$}) { |m| File.dirname(m[0]) } +end + +guard :brakeman, quiet: true do + watch(%r{^app/.+\.(slim|erb|haml|rhtml|rb)$}) + watch(%r{^config/.+\.rb$}) + watch(%r{^lib/.+\.rb$}) + watch('Gemfile') +end + +guard :rspec, cmd: 'bundle exec rspec', all_on_start: true do + watch('spec/spec_helper.rb') { 'spec' } + watch('config/routes.rb') { 'spec/routing' } + watch('app/controllers/application_controller.rb') { 'spec/controllers' } + watch(%r{^spec/.+_spec\.rb$}) + watch(%r{^app/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } + watch(%r{^app/(.*)(\.erb|\.haml|\.slim)$}) { |m| "spec/#{m[1]}#{m[2]}_spec.rb" } + watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" } + watch(%r{^app/controllers/(.+)_(controller)\.rb$}) { |m| ["spec/routing/#{m[1]}_routing_spec.rb", "spec/#{m[2]}s/#{m[1]}_#{m[2]}_spec.rb", "spec/acceptance/#{m[1]}_spec.rb"] } + + # Watch factories + watch(%r{^spec/factories/(.*)s\.rb$}) { |m| "spec/models/#{m[1]}_spec.rb" } +end diff --git a/api/Procfile b/api/Procfile new file mode 100755 index 0000000..8b214ba --- /dev/null +++ b/api/Procfile @@ -0,0 +1,2 @@ +web: bundle exec puma -p $PORT +worker: bundle exec rake sneakers:run diff --git a/api/README.md b/api/README.md new file mode 100755 index 0000000..d1bf37f --- /dev/null +++ b/api/README.md @@ -0,0 +1,51 @@ +# Rails API + +## Installation + +### Dependencies: + +1. Install Terminal Notifier (`brew install terminal-notifier`). If you have already installed Terminal Notifier, ensure it is upgraded to the latest version (`brew upgrade terminal-notifier`). +2. Install ImageMagick (`brew install imagemagick` or `sudo apt-get install imagemagick`) + +### First-Time Setup: + +1. Copy and modify the database.yml file: `cp config/database.yml.example config/database.yml`. If you are using PostgreSQL database then change the adapter to `postgresql` else if you are using MySQL / MariaDB change the adapter to `mysql2`. +2. Copy and modify the .env file: `cp .env.example .env` +3. Run `bundle install` to install all the gems +4. Run `rake db:setup` to create and seed the database +5. Run `rake db:test:load` to load the test database +6. Run `bundle exec guard` to run the server +7. Edit your hosts file (`subl /etc/hosts`) and add the following entry: + + ``` + 127.0.0.1 api.claycms-dev.io + ``` + +9. Now access the app at 'http://api.claycms-dev.io:8080'. + +### Install RabbitMQ for Background Jobs: + +Install RabbitMQ with Homebrew: + +```bash +brew install rabbitmq +``` + +Then, run it (after ensuring that /usr/local/sbin is in your $PATH): + +```bash +rabbitmq-server +``` + +### Handling updates: + +1. Run `bundle install` +2. Run `rake db:migrate` + +### Issues with Autoloading + +If you create new directories under the `app` folder, you might need to run `bin/spring stop` for it to be recognized as all the autoload_paths are computed and cached during initialization. + +### Watch for N+1 queries and unused eager loading + +In development environment, any N+1 queries or unused eager loading would be reported in `log/bullet.log`. diff --git a/api/Rakefile b/api/Rakefile new file mode 100755 index 0000000..393b243 --- /dev/null +++ b/api/Rakefile @@ -0,0 +1,7 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative 'config/application' +require 'sneakers/tasks' + +Rails.application.load_tasks diff --git a/api/app/assets/config/manifest.js b/api/app/assets/config/manifest.js new file mode 100755 index 0000000..b16e53d --- /dev/null +++ b/api/app/assets/config/manifest.js @@ -0,0 +1,3 @@ +//= link_tree ../images +//= link_directory ../javascripts .js +//= link_directory ../stylesheets .css diff --git a/api/app/assets/images/.keep b/api/app/assets/images/.keep new file mode 100755 index 0000000..e69de29 diff --git a/api/app/assets/javascripts/application.js b/api/app/assets/javascripts/application.js new file mode 100755 index 0000000..43ba7e9 --- /dev/null +++ b/api/app/assets/javascripts/application.js @@ -0,0 +1,15 @@ +// This is a manifest file that'll be compiled into application.js, which will include all the files +// listed below. +// +// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, or any plugin's +// vendor/assets/javascripts directory can be referenced here using a relative path. +// +// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the +// compiled file. JavaScript code in this file should be added after the last require_* statement. +// +// Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details +// about supported directives. +// +//= require rails-ujs +//= require activestorage +//= require_tree . diff --git a/api/app/assets/javascripts/cable.js b/api/app/assets/javascripts/cable.js new file mode 100755 index 0000000..739aa5f --- /dev/null +++ b/api/app/assets/javascripts/cable.js @@ -0,0 +1,13 @@ +// Action Cable provides the framework to deal with WebSockets in Rails. +// You can generate new channels where WebSocket features live using the `rails generate channel` command. +// +//= require action_cable +//= require_self +//= require_tree ./channels + +(function() { + this.App || (this.App = {}); + + App.cable = ActionCable.createConsumer(); + +}).call(this); diff --git a/api/app/assets/javascripts/channels/.keep b/api/app/assets/javascripts/channels/.keep new file mode 100755 index 0000000..e69de29 diff --git a/api/app/assets/stylesheets/application.css b/api/app/assets/stylesheets/application.css new file mode 100755 index 0000000..d05ea0f --- /dev/null +++ b/api/app/assets/stylesheets/application.css @@ -0,0 +1,15 @@ +/* + * This is a manifest file that'll be compiled into application.css, which will include all the files + * listed below. + * + * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's + * vendor/assets/stylesheets directory can be referenced here using a relative path. + * + * You're free to add application-wide styles to this file and they'll appear at the bottom of the + * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS + * files in this directory. Styles in this file should be added after the last require_* statement. + * It is generally better to create a new file per style scope. + * + *= require_tree . + *= require_self + */ diff --git a/api/app/channels/application_cable/channel.rb b/api/app/channels/application_cable/channel.rb new file mode 100755 index 0000000..d672697 --- /dev/null +++ b/api/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/api/app/channels/application_cable/connection.rb b/api/app/channels/application_cable/connection.rb new file mode 100755 index 0000000..0ff5442 --- /dev/null +++ b/api/app/channels/application_cable/connection.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + end +end diff --git a/api/app/controllers/application_controller.rb b/api/app/controllers/application_controller.rb new file mode 100755 index 0000000..eeb9f28 --- /dev/null +++ b/api/app/controllers/application_controller.rb @@ -0,0 +1,3 @@ +class ApplicationController < ActionController::API + include ApiErrorHandler +end diff --git a/api/app/controllers/base_current_project_controller.rb b/api/app/controllers/base_current_project_controller.rb new file mode 100644 index 0000000..e5ded82 --- /dev/null +++ b/api/app/controllers/base_current_project_controller.rb @@ -0,0 +1,27 @@ +class BaseCurrentProjectController < ApplicationController + attr_reader :current_project + + before_action :set_current_project + before_action :set_error_context + + private + + def set_current_project + public_key = request.headers['X-Public-Key'] + raise Exceptions::Unauthorized, 'Missing header: X-Public-Key' if public_key.blank? + + key_pair = KeyPair.find_by(public_key: public_key) + raise Exceptions::Unauthorized, 'Incorrect value passed for header: X-Public-Key' if key_pair.blank? + + @current_project = key_pair.project + end + + def set_error_context + return if current_project.blank? + + Raven.user_context( + id: "project-#{current_project.id}", + name: "Project: #{current_project.name}" + ) + end +end diff --git a/api/app/controllers/base_current_user_controller.rb b/api/app/controllers/base_current_user_controller.rb new file mode 100644 index 0000000..e4a43ec --- /dev/null +++ b/api/app/controllers/base_current_user_controller.rb @@ -0,0 +1,29 @@ +class BaseCurrentUserController < ApplicationController + attr_reader :current_user + + before_action :set_current_user + before_action :set_error_context + + private + + def set_current_user + return if request.headers['Authorization'].blank? + + bearer_token = request.headers['Authorization'].split(' ').last + + context = AuthenticateUser.call(bearer_token: bearer_token) + raise Exceptions::Unauthorized, context.error if context.failure? + + @current_user = context.user + end + + def set_error_context + return if current_user.blank? + + Raven.user_context( + id: current_user.id, + email: current_user.email, + name: "#{current_user.first_name} #{current_user.last_name}".strip + ) + end +end diff --git a/api/app/controllers/concerns/api_error_handler.rb b/api/app/controllers/concerns/api_error_handler.rb new file mode 100755 index 0000000..8180e78 --- /dev/null +++ b/api/app/controllers/concerns/api_error_handler.rb @@ -0,0 +1,68 @@ +module ApiErrorHandler + extend ActiveSupport::Concern + + included do + # IMPORTANT: Exceptions are searched bottom to top + # http://apidock.com/rails/ActionController/Rescue/ClassMethods/rescue_from + + rescue_from StandardError do |exception| + raise exception if Rails.env.development? || Rails.env.test? + + Raven.capture_exception(exception) + + render_errors(Exceptions::InternalServerError.new) + end + + rescue_from Exceptions::APIError do |exception| + render_errors(exception) + end + + rescue_from Interactor::Failure do |exception| + render_errors(Exceptions::APIError.new(exception.context.error)) + end + + rescue_from Exceptions::FormErrors do |exception| + render_form_errors(exception.errors) + end + + rescue_from GraphQL::Guard::NotAuthorizedError do |exception| + render_errors(Exceptions::Forbidden.new) + end + + rescue_from ActiveRecord::RecordNotFound do |exception| + render_errors(Exceptions::NotFound.new) + end + + rescue_from ActiveRecord::RecordInvalid do |exception| + render_form_errors(exception.record.errors.to_hash) + end + + rescue_from ActiveRecord::RecordNotDestroyed do |exception| + errors = exception.record.errors.to_hash + if errors.count > 1 + render_form_errors(errors) + else + message = errors.values.first&.join(', ') + render_errors(Exceptions::APIError.new(message)) + end + end + end + + private + + def render_errors(exception) + render json: { errors: [{ message: exception.message, type: exception.type }] }, status: exception.status + end + + def render_form_errors(form_errors) + record_errors = form_errors.deep_transform_keys { |key| key.to_s.camelize(:lower) } + errors = record_errors.map do |attribute, attribute_errors| + { + message: attribute_errors.join(', '), + type: Exceptions::APIError::TYPE, + path: attribute.split('.') + } + end + render json: { errors: errors }, status: Exceptions::APIError::STATUS + end +end diff --git a/api/app/controllers/graphql_controller.rb b/api/app/controllers/graphql_controller.rb new file mode 100755 index 0000000..9dd6b87 --- /dev/null +++ b/api/app/controllers/graphql_controller.rb @@ -0,0 +1,62 @@ +class GraphqlController < BaseCurrentUserController + before_action :set_context + before_action :set_operations + + def execute + if @operations.is_a? Array + queries = @operations.map(&method(:build_query)) + result = ClayApiSchema.multiplex(queries) + else + result = ClayApiSchema.execute(nil, build_query(@operations)) + end + render json: result + end + + private + + def set_context + @context = { + current_user: current_user, + current_request: request + } + end + + def set_operations + if request.content_type != 'multipart/form-data' + @operations = params[:_json] || params + return + end + + @operations = ApolloUploadServer::GraphQLDataBuilder.new.call(params) + @operations.symbolize_keys! + end + + def build_query(document) + { + query: document[:query], + operation_name: document[:operationName], + variables: ensure_hash(document[:variables]), + context: @context + } + end + + # Handle form data, JSON body, or a blank value + def ensure_hash(ambiguous_param) + case ambiguous_param + when String + if ambiguous_param.present? + ensure_hash(JSON.parse(ambiguous_param)) + else + {} + end + when Hash + ambiguous_param + when ActionController::Parameters + ambiguous_param.permit!.to_h + when nil + {} + else + raise ArgumentError, "Unexpected parameter: #{ambiguous_param}" + end + end +end diff --git a/api/app/controllers/v1/content_controller.rb b/api/app/controllers/v1/content_controller.rb new file mode 100644 index 0000000..783febf --- /dev/null +++ b/api/app/controllers/v1/content_controller.rb @@ -0,0 +1,42 @@ +module V1 + class ContentController < BaseCurrentProjectController + def find + entity = current_project.entities.find_by!(name: params[:entity_name]) + + record_scope = entity.records + + filters = params[:filters].presence || {} + + filters.each do |field_name, property_value| + record_scope = record_scope.joins(properties: :field) + record_scope = record_scope.where(fields: { name: field_name }) + record_scope = record_scope.where(properties: { value: property_value }) + end + + # sort = params[:sort] || {} + # sort.each do |field_name, sort_direction| + # record_scope = record_scope.order(field_name, sort_direction) + # end + + # if params[:paging].present? + # record_scope = record_scope.limit(params[:paging][:limit]) if params[:paging][:limit].present? + # record_scope = record_scope.offset(params[:paging][:offset]) if params[:paging][:offset].present? + # end + + result_type = params[:result_type].try(:to_sym) || (entity.singleton? ? :one : :many) + + record_mapper = RecordMapper.new + + if result_type == :one + record = record_scope.first! + data = record_mapper.to_json(record, params[:key_type]) + else + raise Exceptions::APIError, "Cannot find `many` of singleton entity, `#{entity.name}`." if entity.singleton? + + data = record_scope.map { |r| record_mapper.to_json(r, params[:key_type]) } + end + + render json: { data: data } + end + end +end diff --git a/api/app/controllers/v1/entities_controller.rb b/api/app/controllers/v1/entities_controller.rb new file mode 100644 index 0000000..58dec9e --- /dev/null +++ b/api/app/controllers/v1/entities_controller.rb @@ -0,0 +1,15 @@ +module V1 + class EntitiesController < BaseCurrentProjectController + def index + render json: { entities: current_project.entities } + end + + def show + entity = current_project.entities.find(params[:id]) + json = entity.attributes + json[:fields] = entity.fields.to_a + + render json: { entity: json } + end + end +end diff --git a/api/app/graphql/clay_api_schema.rb b/api/app/graphql/clay_api_schema.rb new file mode 100755 index 0000000..aa06c9d --- /dev/null +++ b/api/app/graphql/clay_api_schema.rb @@ -0,0 +1,6 @@ +class ClayApiSchema < GraphQL::Schema + query Roots::QueryType + mutation Roots::MutationType + + use GraphQL::Guard.new(policy_object: GraphqlPolicy) +end diff --git a/api/app/graphql/functions/application_function.rb b/api/app/graphql/functions/application_function.rb new file mode 100755 index 0000000..81b0403 --- /dev/null +++ b/api/app/graphql/functions/application_function.rb @@ -0,0 +1,12 @@ +class ApplicationFunction < GraphQL::Function + include GraphQL::Sugar::Function + + def policy(model_or_record) + @policy = Pundit.policy!(context[:current_user], model_or_record) + end + + def authorize!(model_or_record, permission) + resolved_policy = policy(model_or_record) + raise Exceptions::Forbidden if !resolved_policy.respond_to?(permission) || !resolved_policy.send(permission) + end +end diff --git a/api/app/graphql/graphql_policy.rb b/api/app/graphql/graphql_policy.rb new file mode 100755 index 0000000..020fd9a --- /dev/null +++ b/api/app/graphql/graphql_policy.rb @@ -0,0 +1,38 @@ +class GraphqlPolicy + def self.guard(type, field) + rules.dig(type.name, field) + end + + def self.authorize_user + ->(obj, args, ctx) { ctx[:current_user].present? } + end + + def self.authorize_any + ->(obj, args, ctx) { true } + end + + def self.authorize_none + ->(obj, args, ctx) { false } + end + + def self.current_user_matches?(attribute) + ->(obj, args, ctx) { authorize_user.call(obj, args, ctx) && obj.object.send(attribute) == ctx[:current_user].id } + end + + def self.rules + @rules ||= { + 'Query' => { + '*': authorize_user + }, + 'Mutation' => { + '*': authorize_user, + ssoLogin: authorize_any, + ssoCallback: authorize_any + }, + + 'User' => { + sessions: current_user_matches?(:id) + } + } + end +end diff --git a/api/app/graphql/mutations/.keep b/api/app/graphql/mutations/.keep new file mode 100755 index 0000000..e69de29 diff --git a/api/app/graphql/mutators/accept_transfer_request_mutator.rb b/api/app/graphql/mutators/accept_transfer_request_mutator.rb new file mode 100644 index 0000000..a8c5d5d --- /dev/null +++ b/api/app/graphql/mutators/accept_transfer_request_mutator.rb @@ -0,0 +1,22 @@ +class AcceptTransferRequestMutator < ApplicationMutator + AcceptTransferRequestInputType = GraphQL::InputObjectType.define do + name 'AcceptTransferRequestInput' + + parameter :token, !types.String + end + + parameter :input, !AcceptTransferRequestInputType + + type Types::ResponseType.to_non_null_type + + def mutate + team = Team.find_with_transfer_token(permitted_params[:token]) + raise Exceptions::APIError, 'Your transfer link is invalid.' if team.blank? + + authorize! team, :accept_transfer_request? + + AcceptTransferRequest.call!(team: team) + + { success: true } + end +end diff --git a/api/app/graphql/mutators/application_mutator.rb b/api/app/graphql/mutators/application_mutator.rb new file mode 100755 index 0000000..e230ee1 --- /dev/null +++ b/api/app/graphql/mutators/application_mutator.rb @@ -0,0 +1,9 @@ +class ApplicationMutator < ApplicationFunction + include GraphQL::Sugar::Mutator + + protected + + def permitted_params + params[:input] + end +end diff --git a/api/app/graphql/mutators/cancel_transfer_request_mutator.rb b/api/app/graphql/mutators/cancel_transfer_request_mutator.rb new file mode 100644 index 0000000..8cd015e --- /dev/null +++ b/api/app/graphql/mutators/cancel_transfer_request_mutator.rb @@ -0,0 +1,14 @@ +class CancelTransferRequestMutator < ApplicationMutator + parameter :id, !types.ID + + type Types::TeamType.to_non_null_type + + def mutate + team = Team.find(params[:id]) + authorize! team, :cancel_transfer_request? + + result = CancelTransferRequest.call!(team: team) + + result.team + end +end diff --git a/api/app/graphql/mutators/clone_record_mutator.rb b/api/app/graphql/mutators/clone_record_mutator.rb new file mode 100644 index 0000000..19330b9 --- /dev/null +++ b/api/app/graphql/mutators/clone_record_mutator.rb @@ -0,0 +1,13 @@ +class CloneRecordMutator < ApplicationMutator + parameter :id, !types.ID + + type Types::RecordType.to_non_null_type + + def mutate + record = Record.find(params[:id]) + authorize! record.entity, :clone_record? + + context = CloneRecord.call(record: record) + context.record + end +end diff --git a/api/app/graphql/mutators/create_asset_mutator.rb b/api/app/graphql/mutators/create_asset_mutator.rb new file mode 100644 index 0000000..8afc0de --- /dev/null +++ b/api/app/graphql/mutators/create_asset_mutator.rb @@ -0,0 +1,21 @@ +class CreateAssetMutator < ApplicationMutator + CreateAssetInputType = GraphQL::InputObjectType.define do + name 'CreateAssetInput' + + parameter :projectId, !types.ID + parameter :name, !types.String + parameter :file, Scalars::FileType.to_non_null_type + end + + parameter :input, !CreateAssetInputType + + type Types::AssetType.to_non_null_type + + def mutate + project = Project.find(permitted_params[:project_id]) + authorize! project, :create_asset? + + result = CreateAsset.call!(params: permitted_params, project: project) + result.asset + end +end diff --git a/api/app/graphql/mutators/create_entity_mutator.rb b/api/app/graphql/mutators/create_entity_mutator.rb new file mode 100644 index 0000000..72576a4 --- /dev/null +++ b/api/app/graphql/mutators/create_entity_mutator.rb @@ -0,0 +1,28 @@ +class CreateEntityMutator < ApplicationMutator + CreateEntityInputType = GraphQL::InputObjectType.define do + name 'CreateEntityInput' + + parameter :projectId, !types.ID + parameter :parentId, types.ID + parameter :name, !types.String + parameter :label, !types.String + parameter :singleton, types.Boolean + end + + parameter :input, !CreateEntityInputType + + type Types::EntityType.to_non_null_type + + def mutate + project = Project.find(permitted_params[:project_id]) + authorize! project, :create_entity? + + if permitted_params[:parent_id].present? + entity = Entity.find(permitted_params[:parent_id]) + authorize! entity, :update? + end + + result = CreateEntity.call!(params: permitted_params, project: project) + result.entity + end +end diff --git a/api/app/graphql/mutators/create_field_mutator.rb b/api/app/graphql/mutators/create_field_mutator.rb new file mode 100644 index 0000000..29813f7 --- /dev/null +++ b/api/app/graphql/mutators/create_field_mutator.rb @@ -0,0 +1,30 @@ +class CreateFieldMutator < ApplicationMutator + CreateFieldInputType = GraphQL::InputObjectType.define do + name 'CreateFieldInput' + + parameter :entityId, !types.ID + parameter :name, !types.String + parameter :label, !types.String + parameter :dataType, !types.String + parameter :elementType, types.String + parameter :referencedEntityId, types.ID + parameter :defaultValue, types.String + parameter :hint, types.String + parameter :position, types.Int + parameter :children, types[Scalars::HashType] + parameter :validations, Scalars::HashType + parameter :settings, Scalars::HashType + end + + parameter :input, !CreateFieldInputType + + type Types::FieldType.to_list_type.to_non_null_type + + def mutate + entity = Entity.find(permitted_params[:entity_id]) + authorize! entity, :create_field? + + result = CreateField.call!(params: permitted_params, entity: entity) + result.field.self_and_descendants + end +end diff --git a/api/app/graphql/mutators/create_key_pair_mutator.rb b/api/app/graphql/mutators/create_key_pair_mutator.rb new file mode 100644 index 0000000..df8a9f1 --- /dev/null +++ b/api/app/graphql/mutators/create_key_pair_mutator.rb @@ -0,0 +1,19 @@ +class CreateKeyPairMutator < ApplicationMutator + CreateKeyPairInputType = GraphQL::InputObjectType.define do + name 'CreateKeyPairInput' + + parameter :projectId, !types.ID + end + + parameter :input, !CreateKeyPairInputType + + type Types::KeyPairType.to_non_null_type + + def mutate + project = Project.find(permitted_params[:project_id]) + authorize! project, :create_key_pair? + + result = CreateKeyPair.call!(project: project) + result.key_pair + end +end diff --git a/api/app/graphql/mutators/create_project_mutator.rb b/api/app/graphql/mutators/create_project_mutator.rb new file mode 100644 index 0000000..c9aaee4 --- /dev/null +++ b/api/app/graphql/mutators/create_project_mutator.rb @@ -0,0 +1,20 @@ +class CreateProjectMutator < ApplicationMutator + CreateProjectInputType = GraphQL::InputObjectType.define do + name 'CreateProjectInput' + + parameter :teamId, !types.ID + parameter :name, !types.String + end + + parameter :input, !CreateProjectInputType + + type Types::ProjectType.to_non_null_type + + def mutate + team = Team.find(permitted_params[:team_id]) + authorize! team, :create_project? + + result = CreateProject.call!(params: permitted_params, team: team) + result.project + end +end diff --git a/api/app/graphql/mutators/create_record_mutator.rb b/api/app/graphql/mutators/create_record_mutator.rb new file mode 100644 index 0000000..3154d35 --- /dev/null +++ b/api/app/graphql/mutators/create_record_mutator.rb @@ -0,0 +1,20 @@ +class CreateRecordMutator < ApplicationMutator + CreateRecordInputType = GraphQL::InputObjectType.define do + name 'CreateRecordInput' + + parameter :entityId, !types.ID + parameter :traits, Scalars::HashType + end + + parameter :input, !CreateRecordInputType + + type Types::RecordType.to_non_null_type + + def mutate + entity = Entity.find(permitted_params[:entity_id]) + authorize! entity, :create_record? + + result = CreateRecord.call!(params: permitted_params, entity: entity) + result.record + end +end diff --git a/api/app/graphql/mutators/create_resource_mutator.rb b/api/app/graphql/mutators/create_resource_mutator.rb new file mode 100644 index 0000000..88707bf --- /dev/null +++ b/api/app/graphql/mutators/create_resource_mutator.rb @@ -0,0 +1,21 @@ +class CreateResourceMutator < ApplicationMutator + CreateResourceInputType = GraphQL::InputObjectType.define do + name 'CreateResourceInput' + + parameter :projectId, !types.ID + parameter :name, !types.String + parameter :file, Scalars::FileType.to_non_null_type + end + + parameter :input, !CreateResourceInputType + + type Types::ResourceType.to_non_null_type + + def mutate + project = Project.find(permitted_params[:project_id]) + authorize! project, :create_resource? + + result = CreateResource.call!(params: permitted_params, project: project) + result.resource + end +end diff --git a/api/app/graphql/mutators/create_team_membership_mutator.rb b/api/app/graphql/mutators/create_team_membership_mutator.rb new file mode 100644 index 0000000..e6d323b --- /dev/null +++ b/api/app/graphql/mutators/create_team_membership_mutator.rb @@ -0,0 +1,21 @@ +class CreateTeamMembershipMutator < ApplicationMutator + CreateTeamMembershipInputType = GraphQL::InputObjectType.define do + name 'CreateTeamMembershipInput' + + parameter :teamId, !types.ID + parameter :email, !types.String + parameter :role, !types.String + end + + parameter :input, !CreateTeamMembershipInputType + + type Types::TeamMembershipType.to_non_null_type + + def mutate + team = Team.find(permitted_params[:team_id]) + authorize! team, :create_team_membership? + + result = CreateTeamMembership.call!(params: permitted_params, team: team) + result.team_membership + end +end diff --git a/api/app/graphql/mutators/create_team_mutator.rb b/api/app/graphql/mutators/create_team_mutator.rb new file mode 100644 index 0000000..fa50027 --- /dev/null +++ b/api/app/graphql/mutators/create_team_mutator.rb @@ -0,0 +1,16 @@ +class CreateTeamMutator < ApplicationMutator + CreateTeamInputType = GraphQL::InputObjectType.define do + name 'CreateTeamInput' + + parameter :name, !types.String + end + + parameter :input, !CreateTeamInputType + + type Types::TeamType.to_non_null_type + + def mutate + result = CreateTeam.call!(team_params: permitted_params, current_user: context[:current_user]) + result.team + end +end diff --git a/api/app/graphql/mutators/create_transfer_request_mutator.rb b/api/app/graphql/mutators/create_transfer_request_mutator.rb new file mode 100644 index 0000000..742d0f2 --- /dev/null +++ b/api/app/graphql/mutators/create_transfer_request_mutator.rb @@ -0,0 +1,21 @@ +class CreateTransferRequestMutator < ApplicationMutator + CreateTransferRequestInputType = GraphQL::InputObjectType.define do + name 'CreateTransferRequestInput' + + parameter :userId, !types.ID + end + + parameter :id, !types.ID + parameter :input, !CreateTransferRequestInputType + + type Types::TeamType.to_non_null_type + + def mutate + team = Team.find(params[:id]) + authorize! team, :create_transfer_request? + + CreateTransferRequest.call!(params: permitted_params, team: team) + + team + end +end diff --git a/api/app/graphql/mutators/destroy_asset_mutator.rb b/api/app/graphql/mutators/destroy_asset_mutator.rb new file mode 100644 index 0000000..4be6b0b --- /dev/null +++ b/api/app/graphql/mutators/destroy_asset_mutator.rb @@ -0,0 +1,13 @@ +class DestroyAssetMutator < ApplicationMutator + parameter :id, !types.ID + + type Types::AssetType.to_non_null_type + + def mutate + asset = Asset.find(params[:id]) + authorize! asset, :destroy? + + result = DestroyAsset.call!(asset: asset) + result.asset + end +end diff --git a/api/app/graphql/mutators/destroy_entity_mutator.rb b/api/app/graphql/mutators/destroy_entity_mutator.rb new file mode 100644 index 0000000..a1f95aa --- /dev/null +++ b/api/app/graphql/mutators/destroy_entity_mutator.rb @@ -0,0 +1,13 @@ +class DestroyEntityMutator < ApplicationMutator + parameter :id, !types.ID + + type Types::EntityType.to_non_null_type + + def mutate + entity = Entity.find(params[:id]) + authorize! entity, :destroy? + + result = DestroyEntity.call!(entity: entity) + result.entity + end +end diff --git a/api/app/graphql/mutators/destroy_field_mutator.rb b/api/app/graphql/mutators/destroy_field_mutator.rb new file mode 100644 index 0000000..2f9bb0d --- /dev/null +++ b/api/app/graphql/mutators/destroy_field_mutator.rb @@ -0,0 +1,13 @@ +class DestroyFieldMutator < ApplicationMutator + parameter :id, !types.ID + + type Types::FieldType.to_non_null_type + + def mutate + field = Field.find(params[:id]) + authorize! field, :destroy? + + result = DestroyField.call!(field: field) + result.field + end +end diff --git a/api/app/graphql/mutators/destroy_record_mutator.rb b/api/app/graphql/mutators/destroy_record_mutator.rb new file mode 100644 index 0000000..6f5f20f --- /dev/null +++ b/api/app/graphql/mutators/destroy_record_mutator.rb @@ -0,0 +1,13 @@ +class DestroyRecordMutator < ApplicationMutator + parameter :id, !types.ID + + type Types::RecordType.to_non_null_type + + def mutate + record = Record.find(params[:id]) + authorize! record, :destroy? + + result = DestroyRecord.call!(record: record) + result.record + end +end diff --git a/api/app/graphql/mutators/destroy_resource_mutator.rb b/api/app/graphql/mutators/destroy_resource_mutator.rb new file mode 100644 index 0000000..706e3b9 --- /dev/null +++ b/api/app/graphql/mutators/destroy_resource_mutator.rb @@ -0,0 +1,13 @@ +class DestroyResourceMutator < ApplicationMutator + parameter :id, !types.ID + + type Types::ResourceType.to_non_null_type + + def mutate + resource = Resource.find(params[:id]) + authorize! resource, :destroy? + + result = DestroyResource.call!(resource: resource) + result.resource + end +end diff --git a/api/app/graphql/mutators/destroy_team_membership_mutator.rb b/api/app/graphql/mutators/destroy_team_membership_mutator.rb new file mode 100644 index 0000000..07c99b2 --- /dev/null +++ b/api/app/graphql/mutators/destroy_team_membership_mutator.rb @@ -0,0 +1,13 @@ +class DestroyTeamMembershipMutator < ApplicationMutator + parameter :id, !types.ID + + type Types::TeamMembershipType.to_non_null_type + + def mutate + team_membership = TeamMembership.find(params[:id]) + authorize! team_membership, :destroy? + + result = DestroyTeamMembership.call!(team_membership: team_membership) + result.team_membership + end +end diff --git a/api/app/graphql/mutators/export_project_mutator.rb b/api/app/graphql/mutators/export_project_mutator.rb new file mode 100644 index 0000000..056f969 --- /dev/null +++ b/api/app/graphql/mutators/export_project_mutator.rb @@ -0,0 +1,13 @@ +class ExportProjectMutator < ApplicationMutator + parameter :id, !types.ID + + type Types::ExportType.to_non_null_type + + def mutate + project = Project.find(params[:id]) + authorize! project, :export_project? + + result = ExportProject.call(project: project) + result.export + end +end diff --git a/api/app/graphql/mutators/import_project_mutator.rb b/api/app/graphql/mutators/import_project_mutator.rb new file mode 100644 index 0000000..fa2684e --- /dev/null +++ b/api/app/graphql/mutators/import_project_mutator.rb @@ -0,0 +1,20 @@ +class ImportProjectMutator < ApplicationMutator + ImportProjectInputType = GraphQL::InputObjectType.define do + name 'ImportProjectInput' + + parameter :url, !types.String + end + + parameter :id, !types.ID + parameter :input, !ImportProjectInputType + + type Types::RestoreType.to_non_null_type + + def mutate + project = Project.find(params[:id]) + authorize! project, :import_project? + + result = ImportProject.call(project: project, params: permitted_params) + result.restore + end +end diff --git a/api/app/graphql/mutators/reject_transfer_request_mutator.rb b/api/app/graphql/mutators/reject_transfer_request_mutator.rb new file mode 100644 index 0000000..517fe44 --- /dev/null +++ b/api/app/graphql/mutators/reject_transfer_request_mutator.rb @@ -0,0 +1,22 @@ +class RejectTransferRequestMutator < ApplicationMutator + RejectTransferRequestInputType = GraphQL::InputObjectType.define do + name 'RejectTransferRequestInput' + + parameter :token, !types.String + end + + parameter :input, !RejectTransferRequestInputType + + type Types::ResponseType.to_non_null_type + + def mutate + team = Team.find_with_transfer_token(permitted_params[:token]) + raise Exceptions::APIError, 'Your transfer link is invalid.' if team.blank? + + authorize! team, :reject_transfer_request? + + RejectTransferRequest.call!(team: team) + + { success: true } + end +end diff --git a/api/app/graphql/mutators/revoke_key_pair_mutator.rb b/api/app/graphql/mutators/revoke_key_pair_mutator.rb new file mode 100644 index 0000000..e703680 --- /dev/null +++ b/api/app/graphql/mutators/revoke_key_pair_mutator.rb @@ -0,0 +1,13 @@ +class RevokeKeyPairMutator < ApplicationMutator + parameter :id, !types.ID + + type Types::KeyPairType.to_non_null_type + + def mutate + key_pair = KeyPair.find(params[:id]) + authorize! key_pair, :revoke? + + result = RevokeKeyPair.call!(key_pair: key_pair) + result.key_pair + end +end diff --git a/api/app/graphql/mutators/sort_fields_mutator.rb b/api/app/graphql/mutators/sort_fields_mutator.rb new file mode 100644 index 0000000..3eaebe3 --- /dev/null +++ b/api/app/graphql/mutators/sort_fields_mutator.rb @@ -0,0 +1,31 @@ +class SortFieldsMutator < ApplicationMutator + SortFieldInputType = GraphQL::InputObjectType.define do + name 'SortFieldInput' + + parameter :id, !types.ID + parameter :position, !types.Int + end + + SortFieldsInputType = GraphQL::InputObjectType.define do + name 'SortFieldsInput' + + parameter :fields, !types[SortFieldInputType] + end + + parameter :input, !SortFieldsInputType + + type Types::FieldType.to_list_type + + def mutate + fields = permitted_params[:fields].map do |field_params| + field = Field.find(field_params[:id]) + authorize! field, :update? + + field.position = field_params[:position] + field + end + + result = SortFields.call!(fields: fields) + result.fields + end +end diff --git a/api/app/graphql/mutators/sso_callback_mutator.rb b/api/app/graphql/mutators/sso_callback_mutator.rb new file mode 100644 index 0000000..4a63763 --- /dev/null +++ b/api/app/graphql/mutators/sso_callback_mutator.rb @@ -0,0 +1,20 @@ +class SsoCallbackMutator < ApplicationMutator + SsoCallbackInputType = GraphQL::InputObjectType.define do + name 'SsoCallbackInput' + + parameter :sso, !types.String + parameter :sig, !types.String + end + + parameter :input, !SsoCallbackInputType + + type Scalars::HashType + + def mutate + context = SsoCallback.call( + params: permitted_params, + user_agent: @context[:current_request].user_agent + ) + context.sso_payload + end +end diff --git a/api/app/graphql/mutators/sso_login_mutator.rb b/api/app/graphql/mutators/sso_login_mutator.rb new file mode 100644 index 0000000..570f911 --- /dev/null +++ b/api/app/graphql/mutators/sso_login_mutator.rb @@ -0,0 +1,8 @@ +class SsoLoginMutator < ApplicationMutator + type Scalars::HashType + + def mutate + context = SsoLogin.call + context.sso_payload + end +end diff --git a/api/app/graphql/mutators/sso_logout_mutator.rb b/api/app/graphql/mutators/sso_logout_mutator.rb new file mode 100644 index 0000000..df56342 --- /dev/null +++ b/api/app/graphql/mutators/sso_logout_mutator.rb @@ -0,0 +1,15 @@ +class SsoLogoutMutator < ApplicationMutator + type Types::ResponseType.to_non_null_type + + def mutate + bearer_token = @context.current_request.headers['Authorization'].split(' ').last + decoded_token = JsonWebToken.decode(bearer_token) + + context = SsoLogout.call!( + current_user: @context[:current_user], + decoded_token: decoded_token + ) + + { success: context.response } + end +end diff --git a/api/app/graphql/mutators/update_asset_mutator.rb b/api/app/graphql/mutators/update_asset_mutator.rb new file mode 100644 index 0000000..3a097da --- /dev/null +++ b/api/app/graphql/mutators/update_asset_mutator.rb @@ -0,0 +1,20 @@ +class UpdateAssetMutator < ApplicationMutator + UpdateAssetInputType = GraphQL::InputObjectType.define do + name 'UpdateAssetInput' + + parameter :name, !types.String + end + + parameter :id, !types.ID + parameter :input, !UpdateAssetInputType + + type Types::AssetType.to_non_null_type + + def mutate + asset = Asset.find(params[:id]) + authorize! asset, :update? + + result = UpdateAsset.call!(params: permitted_params, asset: asset) + result.asset + end +end diff --git a/api/app/graphql/mutators/update_entity_mutator.rb b/api/app/graphql/mutators/update_entity_mutator.rb new file mode 100644 index 0000000..37445f2 --- /dev/null +++ b/api/app/graphql/mutators/update_entity_mutator.rb @@ -0,0 +1,28 @@ +class UpdateEntityMutator < ApplicationMutator + UpdateEntityInputType = GraphQL::InputObjectType.define do + name 'UpdateEntityInput' + + parameter :parentId, types.ID + parameter :name, !types.String + parameter :label, !types.String + parameter :singleton, types.Boolean + end + + parameter :id, !types.ID + parameter :input, !UpdateEntityInputType + + type Types::EntityType.to_non_null_type + + def mutate + entity = Entity.find(params[:id]) + authorize! entity, :update? + + if permitted_params[:parent_id].present? + parent = Entity.find(permitted_params[:parent_id]) + authorize! parent, :update? + end + + result = UpdateEntity.call!(params: permitted_params, entity: entity) + result.entity + end +end diff --git a/api/app/graphql/mutators/update_field_mutator.rb b/api/app/graphql/mutators/update_field_mutator.rb new file mode 100644 index 0000000..328ba03 --- /dev/null +++ b/api/app/graphql/mutators/update_field_mutator.rb @@ -0,0 +1,30 @@ +class UpdateFieldMutator < ApplicationMutator + UpdateFieldInputType = GraphQL::InputObjectType.define do + name 'UpdateFieldInput' + + parameter :name, !types.String + parameter :label, !types.String + parameter :dataType, !types.String + parameter :elementType, types.String + parameter :referencedEntityId, types.ID + parameter :defaultValue, types.String + parameter :hint, types.String + parameter :position, types.Int + parameter :children, types[Scalars::HashType] + parameter :validations, Scalars::HashType + parameter :settings, Scalars::HashType + end + + parameter :id, !types.ID + parameter :input, !UpdateFieldInputType + + type Types::FieldType.to_list_type.to_non_null_type + + def mutate + field = Field.find(params[:id]) + authorize! field, :update? + + result = UpdateField.call!(params: permitted_params, field: field) + result.field.self_and_descendants + end +end diff --git a/api/app/graphql/mutators/update_profile_mutator.rb b/api/app/graphql/mutators/update_profile_mutator.rb new file mode 100644 index 0000000..b7758bf --- /dev/null +++ b/api/app/graphql/mutators/update_profile_mutator.rb @@ -0,0 +1,19 @@ +class UpdateProfileMutator < ApplicationMutator + UpdateProfileInputType = GraphQL::InputObjectType.define do + name 'UpdateProfileInput' + + parameter :firstName, types.String + parameter :lastName, types.String + parameter :profilePicture, Scalars::FileType + end + + parameter :input, !UpdateProfileInputType + + type Types::UserType.to_non_null_type + + def mutate + result = UpdateUser.call!(params: permitted_params, user: context[:current_user]) + + result.user + end +end diff --git a/api/app/graphql/mutators/update_project_mutator.rb b/api/app/graphql/mutators/update_project_mutator.rb new file mode 100644 index 0000000..72d0f72 --- /dev/null +++ b/api/app/graphql/mutators/update_project_mutator.rb @@ -0,0 +1,20 @@ +class UpdateProjectMutator < ApplicationMutator + UpdateProjectInputType = GraphQL::InputObjectType.define do + name 'UpdateProjectInput' + + parameter :name, !types.String + end + + parameter :id, !types.ID + parameter :input, !UpdateProjectInputType + + type Types::ProjectType.to_non_null_type + + def mutate + project = Project.find(params[:id]) + authorize! project, :update? + + result = UpdateProject.call!(params: permitted_params, project: project) + result.project + end +end diff --git a/api/app/graphql/mutators/update_record_mutator.rb b/api/app/graphql/mutators/update_record_mutator.rb new file mode 100644 index 0000000..62d1417 --- /dev/null +++ b/api/app/graphql/mutators/update_record_mutator.rb @@ -0,0 +1,20 @@ +class UpdateRecordMutator < ApplicationMutator + UpdateRecordInputType = GraphQL::InputObjectType.define do + name 'UpdateRecordInput' + + parameter :traits, Scalars::HashType + end + + parameter :id, !types.ID + parameter :input, !UpdateRecordInputType + + type Types::RecordType.to_non_null_type + + def mutate + record = Record.find(params[:id]) + authorize! record, :update? + + result = UpdateRecord.call!(params: permitted_params, record: record) + result.record + end +end diff --git a/api/app/graphql/mutators/update_resource_mutator.rb b/api/app/graphql/mutators/update_resource_mutator.rb new file mode 100644 index 0000000..6e2029b --- /dev/null +++ b/api/app/graphql/mutators/update_resource_mutator.rb @@ -0,0 +1,20 @@ +class UpdateResourceMutator < ApplicationMutator + UpdateResourceInputType = GraphQL::InputObjectType.define do + name 'UpdateResourceInput' + + parameter :file, Scalars::FileType.to_non_null_type + end + + parameter :id, !types.ID + parameter :input, !UpdateResourceInputType + + type Types::ResourceType.to_non_null_type + + def mutate + resource = Resource.find(params[:id]) + authorize! resource, :update? + + result = UpdateResource.call!(params: permitted_params, resource: resource) + result.resource + end +end diff --git a/api/app/graphql/mutators/update_team_membership_mutator.rb b/api/app/graphql/mutators/update_team_membership_mutator.rb new file mode 100644 index 0000000..0fe1c9f --- /dev/null +++ b/api/app/graphql/mutators/update_team_membership_mutator.rb @@ -0,0 +1,20 @@ +class UpdateTeamMembershipMutator < ApplicationMutator + UpdateTeamMembershipInputType = GraphQL::InputObjectType.define do + name 'UpdateTeamMembershipInput' + + parameter :role, !types.String + end + + parameter :id, !types.ID + parameter :input, !UpdateTeamMembershipInputType + + type Types::TeamMembershipType.to_non_null_type + + def mutate + team_membership = TeamMembership.find(params[:id]) + authorize! team_membership, :update? + + result = UpdateTeamMembership.call!(params: permitted_params, team_membership: team_membership) + result.team_membership + end +end diff --git a/api/app/graphql/mutators/update_team_mutator.rb b/api/app/graphql/mutators/update_team_mutator.rb new file mode 100644 index 0000000..10ea4c3 --- /dev/null +++ b/api/app/graphql/mutators/update_team_mutator.rb @@ -0,0 +1,20 @@ +class UpdateTeamMutator < ApplicationMutator + UpdateTeamInputType = GraphQL::InputObjectType.define do + name 'UpdateTeamInput' + + parameter :name, !types.String + end + + parameter :id, !types.ID + parameter :input, !UpdateTeamInputType + + type Types::TeamType.to_non_null_type + + def mutate + team = Team.find(params[:id]) + authorize! team, :update? + + result = UpdateTeam.call!(params: permitted_params, team: team) + result.team + end +end diff --git a/api/app/graphql/resolvers/application_resolver.rb b/api/app/graphql/resolvers/application_resolver.rb new file mode 100755 index 0000000..3d88b9c --- /dev/null +++ b/api/app/graphql/resolvers/application_resolver.rb @@ -0,0 +1,39 @@ +class ApplicationResolver < ApplicationFunction + include GraphQL::Sugar::Resolver + + def resolved_object(allowed_classes = []) + parent = object.respond_to?(:object) ? object.object : object + + if allowed_classes.present? + allowed_classes = Array.wrap(allowed_classes) + raise Exceptions::Forbidden if allowed_classes.all? { |c| !parent.is_a?(c) } + end + + parent + end + + def self.sortable + parameter :sort, types.String + parameter :sortDirection, types.String + end + + def self.pageable + parameter :first, types.Int + parameter :skip, types.Int + end + + def sorted_and_paged(records) + paged(sorted(records)) + end + + def sorted(records) + records = records.order(params[:sort] => (params[:sort_direction] || 'asc')) if params[:sort].present? + records + end + + def paged(records) + records = records.limit(params[:first]) if params[:first].present? + records = records.offset(params[:skip]) if params[:skip].present? + records + end +end diff --git a/api/app/graphql/resolvers/assets_resolver.rb b/api/app/graphql/resolvers/assets_resolver.rb new file mode 100644 index 0000000..a615bc2 --- /dev/null +++ b/api/app/graphql/resolvers/assets_resolver.rb @@ -0,0 +1,10 @@ +class AssetsResolver < ApplicationResolver + parameter :projectId, types.ID + + def resolve + parent = resolved_object || Project.find(params[:project_id]) + authorize! parent, :view_assets? + + parent.assets + end +end diff --git a/api/app/graphql/resolvers/current_user_resolver.rb b/api/app/graphql/resolvers/current_user_resolver.rb new file mode 100755 index 0000000..1c56ce3 --- /dev/null +++ b/api/app/graphql/resolvers/current_user_resolver.rb @@ -0,0 +1,7 @@ +class CurrentUserResolver < ApplicationResolver + type Types::UserType.to_non_null_type + + def resolve + context[:current_user] + end +end diff --git a/api/app/graphql/resolvers/entities_resolver.rb b/api/app/graphql/resolvers/entities_resolver.rb new file mode 100644 index 0000000..3eb6d3f --- /dev/null +++ b/api/app/graphql/resolvers/entities_resolver.rb @@ -0,0 +1,10 @@ +class EntitiesResolver < ApplicationResolver + parameter :projectId, types.ID + + def resolve + parent = resolved_object || Project.find(params[:project_id]) + authorize! parent, :view_entities? + + parent.entities.order(:name) + end +end diff --git a/api/app/graphql/resolvers/entity_resolver.rb b/api/app/graphql/resolvers/entity_resolver.rb new file mode 100644 index 0000000..ba5b6b6 --- /dev/null +++ b/api/app/graphql/resolvers/entity_resolver.rb @@ -0,0 +1,10 @@ +class EntityResolver < ApplicationResolver + parameter :id, !types.ID + + def resolve + entity = Entity.find(params[:id]) + authorize! entity, :view? + + entity + end +end diff --git a/api/app/graphql/resolvers/exports_resolver.rb b/api/app/graphql/resolvers/exports_resolver.rb new file mode 100644 index 0000000..e90934f --- /dev/null +++ b/api/app/graphql/resolvers/exports_resolver.rb @@ -0,0 +1,10 @@ +class ExportsResolver < ApplicationResolver + parameter :projectId, types.ID + + def resolve + parent = resolved_object || Project.find(params[:project_id]) + authorize! parent, :view_exports? + + parent.exports.order(created_at: :desc) + end +end diff --git a/api/app/graphql/resolvers/fields_resolver.rb b/api/app/graphql/resolvers/fields_resolver.rb new file mode 100644 index 0000000..42ca086 --- /dev/null +++ b/api/app/graphql/resolvers/fields_resolver.rb @@ -0,0 +1,10 @@ +class FieldsResolver < ApplicationResolver + parameter :entityId, types.ID + + def resolve + entity = resolved_object || Entity.find(params[:entity_id]) + authorize! entity, :view? + + entity.nested_fields + end +end diff --git a/api/app/graphql/resolvers/key_pairs_resolver.rb b/api/app/graphql/resolvers/key_pairs_resolver.rb new file mode 100644 index 0000000..61c06ad --- /dev/null +++ b/api/app/graphql/resolvers/key_pairs_resolver.rb @@ -0,0 +1,10 @@ +class KeyPairsResolver < ApplicationResolver + parameter :projectId, types.ID + + def resolve + parent = resolved_object || Project.find(params[:project_id]) + authorize! parent, :view_key_pairs? + + parent.key_pairs + end +end diff --git a/api/app/graphql/resolvers/project_resolver.rb b/api/app/graphql/resolvers/project_resolver.rb new file mode 100644 index 0000000..a5c3382 --- /dev/null +++ b/api/app/graphql/resolvers/project_resolver.rb @@ -0,0 +1,10 @@ +class ProjectResolver < ApplicationResolver + parameter :id, !types.ID + + def resolve + project = Project.find(params[:id]) + authorize! project, :view? + + project + end +end diff --git a/api/app/graphql/resolvers/projects_resolver.rb b/api/app/graphql/resolvers/projects_resolver.rb new file mode 100644 index 0000000..6b9f599 --- /dev/null +++ b/api/app/graphql/resolvers/projects_resolver.rb @@ -0,0 +1,13 @@ +class ProjectsResolver < ApplicationResolver + sortable + pageable + + parameter :teamId, !types.ID + + def resolve + team = Team.find(params[:team_id]) + authorize! team, :view_projects? + + sorted_and_paged(team.projects) + end +end diff --git a/api/app/graphql/resolvers/record_resolver.rb b/api/app/graphql/resolvers/record_resolver.rb new file mode 100644 index 0000000..2552e73 --- /dev/null +++ b/api/app/graphql/resolvers/record_resolver.rb @@ -0,0 +1,10 @@ +class RecordResolver < ApplicationResolver + parameter :recordId, types.ID + + def resolve + record = Record.find(params[:record_id]) + authorize! record.entity, :view? + + record + end +end diff --git a/api/app/graphql/resolvers/records_resolver.rb b/api/app/graphql/resolvers/records_resolver.rb new file mode 100644 index 0000000..ccab487 --- /dev/null +++ b/api/app/graphql/resolvers/records_resolver.rb @@ -0,0 +1,10 @@ +class RecordsResolver < ApplicationResolver + parameter :entityId, types.ID + + def resolve + entity = resolved_object || Entity.find(params[:entity_id]) + authorize! entity, :view? + + entity.records + end +end diff --git a/api/app/graphql/resolvers/referenced_entities_resolver.rb b/api/app/graphql/resolvers/referenced_entities_resolver.rb new file mode 100644 index 0000000..945df8b --- /dev/null +++ b/api/app/graphql/resolvers/referenced_entities_resolver.rb @@ -0,0 +1,12 @@ +class ReferencedEntitiesResolver < ApplicationResolver + parameter :entityId, types.ID + + type Types::EntityType.to_list_type + + def resolve + entity = Entity.find(params[:entity_id]) + authorize! entity.project, :view_entities? + + Entity.where(parent_id: entity.id).or(Entity.where(id: entity.id)) + end +end diff --git a/api/app/graphql/resolvers/resources_resolver.rb b/api/app/graphql/resolvers/resources_resolver.rb new file mode 100644 index 0000000..e0292fd --- /dev/null +++ b/api/app/graphql/resolvers/resources_resolver.rb @@ -0,0 +1,10 @@ +class ResourcesResolver < ApplicationResolver + parameter :projectId, types.ID + + def resolve + parent = resolved_object || Project.find(params[:project_id]) + authorize! parent, :view_resources? + + parent.resources + end +end diff --git a/api/app/graphql/resolvers/restores_resolver.rb b/api/app/graphql/resolvers/restores_resolver.rb new file mode 100644 index 0000000..cd4148c --- /dev/null +++ b/api/app/graphql/resolvers/restores_resolver.rb @@ -0,0 +1,10 @@ +class RestoresResolver < ApplicationResolver + parameter :projectId, types.ID + + def resolve + parent = resolved_object || Project.find(params[:project_id]) + authorize! parent, :view_imports? + + parent.restores.order(created_at: :desc) + end +end diff --git a/api/app/graphql/resolvers/team_memberships_resolver.rb b/api/app/graphql/resolvers/team_memberships_resolver.rb new file mode 100644 index 0000000..76db3ef --- /dev/null +++ b/api/app/graphql/resolvers/team_memberships_resolver.rb @@ -0,0 +1,12 @@ +class TeamMembershipsResolver < ApplicationResolver + pageable + + parameter :teamId, types.ID + + def resolve + parent_object = resolved_object || context[:current_user].teams.find(params[:team_id]) + authorize! parent_object, :view_team_memberships? + + paged(parent_object.team_memberships) + end +end diff --git a/api/app/graphql/resolvers/team_resolver.rb b/api/app/graphql/resolvers/team_resolver.rb new file mode 100644 index 0000000..9eba774 --- /dev/null +++ b/api/app/graphql/resolvers/team_resolver.rb @@ -0,0 +1,7 @@ +class TeamResolver < ApplicationResolver + parameter :id, !types.ID + + def resolve + context[:current_user].teams.find(params[:id]) + end +end diff --git a/api/app/graphql/resolvers/teams_resolver.rb b/api/app/graphql/resolvers/teams_resolver.rb new file mode 100644 index 0000000..b730685 --- /dev/null +++ b/api/app/graphql/resolvers/teams_resolver.rb @@ -0,0 +1,8 @@ +class TeamsResolver < ApplicationResolver + sortable + pageable + + def resolve + sorted_and_paged(context[:current_user].teams) + end +end diff --git a/api/app/graphql/roots/mutation_type.rb b/api/app/graphql/roots/mutation_type.rb new file mode 100755 index 0000000..1d7b386 --- /dev/null +++ b/api/app/graphql/roots/mutation_type.rb @@ -0,0 +1,52 @@ +module Roots + class MutationType < GraphQL::Schema::Object + include GraphQL::Sugar::Mutation + + mutator :updateProfile + + mutator :createTeam + mutator :updateTeam + mutator :createTransferRequest + mutator :cancelTransferRequest + mutator :acceptTransferRequest + mutator :rejectTransferRequest + + mutator :createTeamMembership + mutator :updateTeamMembership + mutator :destroyTeamMembership + + mutator :createProject + mutator :updateProject + mutator :exportProject + mutator :importProject + + mutator :createKeyPair + mutator :revokeKeyPair + + mutator :createAsset + mutator :updateAsset + mutator :destroyAsset + + mutator :createEntity + mutator :updateEntity + mutator :destroyEntity + + mutator :createField + mutator :updateField + mutator :destroyField + mutator :sortFields + + mutator :createRecord + mutator :cloneRecord + mutator :updateRecord + mutator :destroyRecord + + mutator :createResource + mutator :updateResource + mutator :destroyResource + + mutator :ssoLogin + mutator :ssoCallback + mutator :ssoLogout + end +end diff --git a/api/app/graphql/roots/query_type.rb b/api/app/graphql/roots/query_type.rb new file mode 100755 index 0000000..789357c --- /dev/null +++ b/api/app/graphql/roots/query_type.rb @@ -0,0 +1,39 @@ +module Roots + class QueryType < GraphQL::Schema::Object + include GraphQL::Sugar::Query + + resolver :currentUser + + resolver :teams + + resolver :team + + resolver :teamMemberships + + resolver :projects + + resolver :project + + resolver :keyPairs + + resolver :assets + + resolver :entities + + resolver :referencedEntities + + resolver :entity + + resolver :fields + + resolver :records + + resolver :record + + resolver :exports + + resolver :restores + + resolver :resources + end +end diff --git a/api/app/graphql/scalars/base_scalar_type.rb b/api/app/graphql/scalars/base_scalar_type.rb new file mode 100755 index 0000000..483aad9 --- /dev/null +++ b/api/app/graphql/scalars/base_scalar_type.rb @@ -0,0 +1,4 @@ +module Scalars + class BaseScalarType < GraphQL::Schema::Scalar + end +end diff --git a/api/app/graphql/scalars/file_type.rb b/api/app/graphql/scalars/file_type.rb new file mode 100644 index 0000000..c518a75 --- /dev/null +++ b/api/app/graphql/scalars/file_type.rb @@ -0,0 +1,13 @@ +module Scalars + class FileType < BaseScalarType + graphql_name 'File' + + def self.coerce_input(value, _ctx) + value + end + + def self.coerce_result(value, _ctx) + value + end + end +end diff --git a/api/app/graphql/scalars/hash_type.rb b/api/app/graphql/scalars/hash_type.rb new file mode 100644 index 0000000..8b34416 --- /dev/null +++ b/api/app/graphql/scalars/hash_type.rb @@ -0,0 +1,13 @@ +module Scalars + class HashType < BaseScalarType + graphql_name 'Hash' + + def self.coerce_input(value, _ctx) + value.deep_transform_keys { |key| key.to_s.underscore } + end + + def self.coerce_result(value, _ctx) + value.deep_transform_keys { |key| key.to_s.camelize(:lower) } + end + end +end diff --git a/api/app/graphql/scalars/json_type.rb b/api/app/graphql/scalars/json_type.rb new file mode 100644 index 0000000..5d3fa6e --- /dev/null +++ b/api/app/graphql/scalars/json_type.rb @@ -0,0 +1,13 @@ +module Scalars + class JsonType < BaseScalarType + graphql_name 'Json' + + def self.coerce_input(value, _ctx) + value + end + + def self.coerce_result(value, _ctx) + value + end + end +end diff --git a/api/app/graphql/types/application_type.rb b/api/app/graphql/types/application_type.rb new file mode 100755 index 0000000..b137c0b --- /dev/null +++ b/api/app/graphql/types/application_type.rb @@ -0,0 +1,19 @@ +module Types + class ApplicationType < GraphQL::Schema::Object + include GraphQL::Sugar::Object + + def self.file_field(name, version = nil, null: true, **options) + field_name = version.present? ? "#{name}_#{version}" : name + + field field_name, String, null: null + + define_method field_name do + if version.present? && version != :original + object.send("#{name}_url", version, **options) + else + object.send("#{name}_url", **options) + end + end + end + end +end diff --git a/api/app/graphql/types/asset_type.rb b/api/app/graphql/types/asset_type.rb new file mode 100644 index 0000000..472aa25 --- /dev/null +++ b/api/app/graphql/types/asset_type.rb @@ -0,0 +1,12 @@ +module Types + class AssetType < ApplicationType + model_class Asset + + attribute :name + file_field :file, :original, public: true + + field :metadata, Scalars::HashType, null: false, method: :metadata + + relationship :project + end +end diff --git a/api/app/graphql/types/entity_type.rb b/api/app/graphql/types/entity_type.rb new file mode 100644 index 0000000..526ef43 --- /dev/null +++ b/api/app/graphql/types/entity_type.rb @@ -0,0 +1,14 @@ +module Types + class EntityType < ApplicationType + model_class Entity + + attribute :label + attribute :name + attribute :singleton + + relationship :fields + relationship :parent + relationship :project + relationship :records + end +end diff --git a/api/app/graphql/types/export_type.rb b/api/app/graphql/types/export_type.rb new file mode 100644 index 0000000..7c17677 --- /dev/null +++ b/api/app/graphql/types/export_type.rb @@ -0,0 +1,13 @@ +module Types + class ExportType < ApplicationType + model_class Export + + attribute :status + + file_field :file, public: true + + field :metadata, Scalars::HashType, null: true, resolve: ->(obj, args, ctx) { obj&.file&.metadata } + + relationship :project + end +end diff --git a/api/app/graphql/types/field_type.rb b/api/app/graphql/types/field_type.rb new file mode 100644 index 0000000..4ee71d6 --- /dev/null +++ b/api/app/graphql/types/field_type.rb @@ -0,0 +1,22 @@ +module Types + class FieldType < ApplicationType + model_class Field + + attribute :data_type + attribute :default_value + attribute :editor + attribute :element_type + attribute :referenced_entity_id + attribute :hint + attribute :label + attribute :name + attribute :position + attribute :validations, Scalars::JsonType, null: true + attribute :settings, Scalars::JsonType, null: true + + field :parentId, ID, null: true + + relationship :entity + relationship :children + end +end diff --git a/api/app/graphql/types/key_pair_type.rb b/api/app/graphql/types/key_pair_type.rb new file mode 100644 index 0000000..c8e10d7 --- /dev/null +++ b/api/app/graphql/types/key_pair_type.rb @@ -0,0 +1,9 @@ +module Types + class KeyPairType < ApplicationType + model_class KeyPair + + attribute :public_key + + relationship :project + end +end diff --git a/api/app/graphql/types/locale_type.rb b/api/app/graphql/types/locale_type.rb new file mode 100644 index 0000000..03f62fe --- /dev/null +++ b/api/app/graphql/types/locale_type.rb @@ -0,0 +1,9 @@ +module Types + class LocaleType < ApplicationType + model_class Locale + + attribute :language + + relationship :project + end +end diff --git a/api/app/graphql/types/project_type.rb b/api/app/graphql/types/project_type.rb new file mode 100644 index 0000000..6260048 --- /dev/null +++ b/api/app/graphql/types/project_type.rb @@ -0,0 +1,12 @@ +module Types + class ProjectType < ApplicationType + model_class Project + + attribute :name + + field :isRestoring, Boolean, null: false, resolve: ->(obj, args, ctx) { obj.restores.pending.present? } + + relationship :team + relationship :entities + end +end diff --git a/api/app/graphql/types/property_type.rb b/api/app/graphql/types/property_type.rb new file mode 100644 index 0000000..aea2557 --- /dev/null +++ b/api/app/graphql/types/property_type.rb @@ -0,0 +1,16 @@ +module Types + class PropertyType < ApplicationType + model_class Property + + attribute :value + attribute :position + + field :parentId, String, null: true + + relationship :asset + relationship :children + relationship :field + relationship :linked_record + relationship :record + end +end diff --git a/api/app/graphql/types/record_type.rb b/api/app/graphql/types/record_type.rb new file mode 100644 index 0000000..4a6dc84 --- /dev/null +++ b/api/app/graphql/types/record_type.rb @@ -0,0 +1,9 @@ +module Types + class RecordType < ApplicationType + model_class Record + + field :properties, [Types::PropertyType], null: false, method: :nested_properties + + relationship :entity + end +end diff --git a/api/app/graphql/types/relationship_type.rb b/api/app/graphql/types/relationship_type.rb new file mode 100644 index 0000000..e5c45ce --- /dev/null +++ b/api/app/graphql/types/relationship_type.rb @@ -0,0 +1,12 @@ +module Types + class RelationshipType < ApplicationType + model_class Relationship + + relationship :entity + relationship :field + + # todos + # - add linked entity + # - add linked fied + end +end diff --git a/api/app/graphql/types/resource_type.rb b/api/app/graphql/types/resource_type.rb new file mode 100644 index 0000000..d27f5c1 --- /dev/null +++ b/api/app/graphql/types/resource_type.rb @@ -0,0 +1,12 @@ +module Types + class ResourceType < ApplicationType + model_class Resource + + attribute :name + file_field :file, public: true + + field :metadata, Scalars::HashType, null: false, resolve: ->(obj, args, ctx) { obj.file.metadata } + + relationship :project + end +end diff --git a/api/app/graphql/types/response_type.rb b/api/app/graphql/types/response_type.rb new file mode 100755 index 0000000..e1c3ac0 --- /dev/null +++ b/api/app/graphql/types/response_type.rb @@ -0,0 +1,5 @@ +Types::ResponseType = GraphQL::ObjectType.define do + name 'Response' + + field :success, !types.Boolean, hash_key: :success +end diff --git a/api/app/graphql/types/restore_type.rb b/api/app/graphql/types/restore_type.rb new file mode 100644 index 0000000..8ecb74d --- /dev/null +++ b/api/app/graphql/types/restore_type.rb @@ -0,0 +1,10 @@ +module Types + class RestoreType < ApplicationType + model_class Restore + + attribute :status + attribute :url + + relationship :project + end +end diff --git a/api/app/graphql/types/team_membership_type.rb b/api/app/graphql/types/team_membership_type.rb new file mode 100644 index 0000000..b57787f --- /dev/null +++ b/api/app/graphql/types/team_membership_type.rb @@ -0,0 +1,10 @@ +module Types + class TeamMembershipType < ApplicationType + model_class TeamMembership + + attribute :role + + relationship :team + relationship :user + end +end diff --git a/api/app/graphql/types/team_type.rb b/api/app/graphql/types/team_type.rb new file mode 100644 index 0000000..7fe87b9 --- /dev/null +++ b/api/app/graphql/types/team_type.rb @@ -0,0 +1,11 @@ +module Types + class TeamType < ApplicationType + model_class Team + + attribute :name + field :isTransferRequested, Boolean, null: false, method: :transfer_requested? + + relationship :transfer_owner + relationship :team_memberships + end +end diff --git a/api/app/graphql/types/user_type.rb b/api/app/graphql/types/user_type.rb new file mode 100755 index 0000000..6cb0f0f --- /dev/null +++ b/api/app/graphql/types/user_type.rb @@ -0,0 +1,14 @@ +module Types + class UserType < ApplicationType + model_class User + + attribute :first_name + attribute :last_name + attribute :email + + file_field :profile_picture, :thumbnail + file_field :profile_picture, :normal + + relationship :team_memberships + end +end diff --git a/api/app/helpers/application_helper.rb b/api/app/helpers/application_helper.rb new file mode 100755 index 0000000..de6be79 --- /dev/null +++ b/api/app/helpers/application_helper.rb @@ -0,0 +1,2 @@ +module ApplicationHelper +end diff --git a/api/app/interactors/accept_transfer_request.rb b/api/app/interactors/accept_transfer_request.rb new file mode 100644 index 0000000..33f9222 --- /dev/null +++ b/api/app/interactors/accept_transfer_request.rb @@ -0,0 +1,24 @@ +class AcceptTransferRequest + include Interactor + + def call + ActiveRecord::Base.transaction do + verify_transfer_request + accept_transfer_request + end + end + + def verify_transfer_request + context.fail!(error: 'Your transfer link has either expired or been canceled.') if !context.team.transfer_requested? + end + + def accept_transfer_request + old_owner_membership = context.team.team_memberships.find_by(role: :owner) + new_owner_membership = context.team.team_memberships.find_by(user_id: context.team.transfer_owner_id) + + old_owner_membership.update!(role: :manager) + new_owner_membership.update!(role: :owner) + + context.team.reset_transfer! + end +end diff --git a/api/app/interactors/authenticate_user.rb b/api/app/interactors/authenticate_user.rb new file mode 100644 index 0000000..d8fce4e --- /dev/null +++ b/api/app/interactors/authenticate_user.rb @@ -0,0 +1,30 @@ +class AuthenticateUser + include Interactor + + def call + decode_auth_token + find_user + validate_token + end + + protected + + def decode_auth_token + @decoded_token = JsonWebToken.decode(context.bearer_token) + rescue JWT::ExpiredSignature + context.fail!(error: 'Your session has expired. Please login again to continue.') + end + + def find_user + context.user = User.find_by(id: @decoded_token['user_id']) + context.fail!(error: 'User not found') unless context.user + end + + def validate_token + revoked = AuthToken.revoked?( + user: context.user, + jti: @decoded_token['jti'] + ) + context.fail!(error: 'Your session has expired. Please login again to continue.') if revoked + end +end diff --git a/api/app/interactors/cancel_transfer_request.rb b/api/app/interactors/cancel_transfer_request.rb new file mode 100644 index 0000000..71b6e88 --- /dev/null +++ b/api/app/interactors/cancel_transfer_request.rb @@ -0,0 +1,7 @@ +class CancelTransferRequest + include Interactor + + def call + context.team.reset_transfer! + end +end diff --git a/api/app/interactors/clone_record.rb b/api/app/interactors/clone_record.rb new file mode 100644 index 0000000..fd49116 --- /dev/null +++ b/api/app/interactors/clone_record.rb @@ -0,0 +1,8 @@ +class CloneRecord + include Interactor + + def call + context.record = context.record.amoeba_dup + context.record.save!(validate: false) + end +end diff --git a/api/app/interactors/create_asset.rb b/api/app/interactors/create_asset.rb new file mode 100644 index 0000000..984f381 --- /dev/null +++ b/api/app/interactors/create_asset.rb @@ -0,0 +1,7 @@ +class CreateAsset + include Interactor + + def call + context.asset = context.project.assets.create!(context.params) + end +end diff --git a/api/app/interactors/create_entity.rb b/api/app/interactors/create_entity.rb new file mode 100644 index 0000000..592afbf --- /dev/null +++ b/api/app/interactors/create_entity.rb @@ -0,0 +1,7 @@ +class CreateEntity + include Interactor + + def call + context.entity = context.project.entities.create!(context.params) + end +end diff --git a/api/app/interactors/create_field.rb b/api/app/interactors/create_field.rb new file mode 100644 index 0000000..6f0d183 --- /dev/null +++ b/api/app/interactors/create_field.rb @@ -0,0 +1,53 @@ +class CreateField + include Interactor + + def call + ActiveRecord::Base.transaction do + process + create_field + end + end + + protected + + def process + context.entities = context.entity.project.entities + context.field_attributes = process_field(context.params) + end + + def create_field + context.field = context.entity.fields.create!(context.field_attributes) + end + + def process_field(field) + field_params = field.except(:children, :referenced_entity_name) + + if context.is_import + referenced_entity = context.entities.find_by(name: field[:referenced_entity_name]) + field_params[:referenced_entity_id] = referenced_entity&.id + end + + children_attributes = process_children(field_params, field[:children]) + field_params[:children_attributes] = children_attributes if children_attributes.present? + + field_params + end + + def process_children(parent, children) + if parent[:data_type]&.to_sym == :array + sub_parent = { + name: "#{parent[:name]}_item", + label: parent[:label].singularize, + position: 0, + data_type: parent[:element_type], + referenced_entity_id: parent[:referenced_entity_id] + } + + sub_parent[:children_attributes] = process_children(sub_parent, children) if children.present? + + [sub_parent] + else + (children || []).map { |child| process_field(child) } + end + end +end diff --git a/api/app/interactors/create_key_pair.rb b/api/app/interactors/create_key_pair.rb new file mode 100644 index 0000000..989e2c0 --- /dev/null +++ b/api/app/interactors/create_key_pair.rb @@ -0,0 +1,7 @@ +class CreateKeyPair + include Interactor + + def call + context.key_pair = context.project.key_pairs.create! + end +end diff --git a/api/app/interactors/create_project.rb b/api/app/interactors/create_project.rb new file mode 100644 index 0000000..2b9846d --- /dev/null +++ b/api/app/interactors/create_project.rb @@ -0,0 +1,10 @@ +class CreateProject + include Interactor + + def call + ActiveRecord::Base.transaction do + context.project = context.team.projects.create!(context.params.slice(:name)) + context.project.key_pairs.create! + end + end +end diff --git a/api/app/interactors/create_record.rb b/api/app/interactors/create_record.rb new file mode 100644 index 0000000..c6a0d0d --- /dev/null +++ b/api/app/interactors/create_record.rb @@ -0,0 +1,110 @@ +class CreateRecord + include Interactor + + def call + ActiveRecord::Base.transaction do + process_traits + create_record + end + end + + protected + + def process_traits + context.properties_attributes = (context.params[:traits] || {}).map do |field_name, value| + field = context.entity.fields.find_by(name: field_name) + + process_value(field, value) + end + end + + def create_record + context.record = context.entity.records.create!( + properties_attributes: context.properties_attributes + ) + end + + def process_value(field, value, position = 0) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + property = {} + + if field.array? + property = { field: field, children_attributes: process_array(field, value) } + elsif field.key_value? + property = { field: field, children_attributes: process_hash(field, value) } + elsif field.reference? + if context.is_import + imported_record_id = value&.is_a?(Hash) ? value[:id] : value + property = { field: field, value: "____ref_id____#{imported_record_id}" } + else + property = { field: field, linked_record: process_reference(value) } + end + elsif field.image? || field.file? + property = { field: field, asset: process_asset(value) } + else + property = { field: field, value: value } + end + + property[:position] = position + + property + end + + def process_array(field, values) + return [] if !values.is_a? Array + + sub_field = field.children.first + + processed_values = values.map do |value| + next if value.except(:position).blank? + + input_value = sub_field.reference? ? value : value[:value] + process_value(sub_field, input_value, value[:position]) + end + + processed_values.compact + end + + def process_hash(field, values) + return [] if !values.is_a? Hash + + values.map do |field_name, value| + child_field = field.children.find_by(name: field_name) + + process_value(child_field, value) + end + end + + def process_reference(value) + return nil if context.is_import + + linked_record = Record.find_by(id: value[:id]) if value[:id].present? + + if value[:traits].present? + if linked_record.present? + linked_record = UpdateRecord.call!(params: value, record: linked_record).record + else + entity = Entity.find_by(id: value[:entity_id]) + linked_record = CreateRecord.call!(params: value, entity: entity).record + end + end + + linked_record + end + + def process_asset(value) + if context.is_import && value.present? && value[:name].present? + begin + return context.entity.project.assets.create!(name: value[:name], file_remote_url: value[:url]) + rescue StandardError => e + Rails.logger.debug "Failed to add #{value}" + Rails.logger.debug e + end + end + + return Asset.find(value[:id]) if value.is_a?(Hash) && value[:id].present? + + return if !value.respond_to?(:content_type) + + context.entity.project.assets.create!(name: value.original_filename, file: value) + end +end diff --git a/api/app/interactors/create_resource.rb b/api/app/interactors/create_resource.rb new file mode 100644 index 0000000..df45715 --- /dev/null +++ b/api/app/interactors/create_resource.rb @@ -0,0 +1,21 @@ +class CreateResource + include Interactor + + def call + validate + create + end + + protected + + def validate + name = context.params[:name] + resource = context.project.resources.find_by(name: name) + + context.fail!(error: "#{name} already exists, please try with different name or update the existing one.") if resource.present? + end + + def create + context.resource = context.project.resources.create!(context.params) + end +end diff --git a/api/app/interactors/create_team.rb b/api/app/interactors/create_team.rb new file mode 100644 index 0000000..d112d37 --- /dev/null +++ b/api/app/interactors/create_team.rb @@ -0,0 +1,23 @@ +class CreateTeam + include Interactor + + def call + ActiveRecord::Base.transaction do + create_team + create_team_membership + end + end + + protected + + def create_team + context.team = Team.create!(context.team_params) + end + + def create_team_membership + context.team.team_memberships.create!( + user: context.current_user, + role: :owner + ) + end +end diff --git a/api/app/interactors/create_team_membership.rb b/api/app/interactors/create_team_membership.rb new file mode 100644 index 0000000..cf51e56 --- /dev/null +++ b/api/app/interactors/create_team_membership.rb @@ -0,0 +1,34 @@ +class CreateTeamMembership + include Interactor + + def call + ActiveRecord::Base.transaction do + validate_role + find_or_create_user + create_team_membership + end + end + + protected + + def validate_role + return if context.params[:role] != 'owner' + + context.fail!(error: 'Role cannot be owner.') + end + + def find_or_create_user + context.user = User.find_or_create_by!(email: context.params[:email]) + end + + def create_team_membership + team_membership = context.team.team_memberships.find_by(user: context.user) + + context.fail!(error: 'Email is already added to the team.') if team_membership.present? + + context.team_membership = context.team.team_memberships.create!( + user: context.user, + role: context.params[:role] + ) + end +end diff --git a/api/app/interactors/create_transfer_request.rb b/api/app/interactors/create_transfer_request.rb new file mode 100644 index 0000000..bf59d53 --- /dev/null +++ b/api/app/interactors/create_transfer_request.rb @@ -0,0 +1,27 @@ +class CreateTransferRequest + include Interactor + + def call + ActiveRecord::Base.transaction do + find_user + validate_user + initiate_transfer + end + end + + def find_user + @user = User.find_by(id: context.params[:user_id]) + end + + def validate_user + context.fail!(error: 'The user you have selected does not exist.') if @user.blank? + + team_membership = context.team.team_memberships.find_by(user: @user) + context.fail!(error: 'The user you have selected does not belong to this team.') if team_membership.blank? + context.fail!(error: 'The user you have selected is already the owner of this team.') if team_membership.owner? + end + + def initiate_transfer + context.team.request_transfer_to!(@user) + end +end diff --git a/api/app/interactors/destroy_asset.rb b/api/app/interactors/destroy_asset.rb new file mode 100644 index 0000000..b79157f --- /dev/null +++ b/api/app/interactors/destroy_asset.rb @@ -0,0 +1,7 @@ +class DestroyAsset + include Interactor + + def call + context.asset.destroy! + end +end diff --git a/api/app/interactors/destroy_entity.rb b/api/app/interactors/destroy_entity.rb new file mode 100644 index 0000000..6aa4a5f --- /dev/null +++ b/api/app/interactors/destroy_entity.rb @@ -0,0 +1,7 @@ +class DestroyEntity + include Interactor + + def call + context.entity.destroy! + end +end diff --git a/api/app/interactors/destroy_field.rb b/api/app/interactors/destroy_field.rb new file mode 100644 index 0000000..d0fb3c4 --- /dev/null +++ b/api/app/interactors/destroy_field.rb @@ -0,0 +1,7 @@ +class DestroyField + include Interactor + + def call + context.field.destroy! + end +end diff --git a/api/app/interactors/destroy_record.rb b/api/app/interactors/destroy_record.rb new file mode 100644 index 0000000..9dbeb27 --- /dev/null +++ b/api/app/interactors/destroy_record.rb @@ -0,0 +1,7 @@ +class DestroyRecord + include Interactor + + def call + context.record.destroy! + end +end diff --git a/api/app/interactors/destroy_resource.rb b/api/app/interactors/destroy_resource.rb new file mode 100644 index 0000000..3014046 --- /dev/null +++ b/api/app/interactors/destroy_resource.rb @@ -0,0 +1,7 @@ +class DestroyResource + include Interactor + + def call + context.resource.destroy! + end +end diff --git a/api/app/interactors/destroy_team_membership.rb b/api/app/interactors/destroy_team_membership.rb new file mode 100644 index 0000000..5306fb6 --- /dev/null +++ b/api/app/interactors/destroy_team_membership.rb @@ -0,0 +1,24 @@ +class DestroyTeamMembership + include Interactor + + def call + ActiveRecord::Base.transaction do + verify_team_membership + destroy_team_membership + clean_up_transfer_requests + end + end + + def verify_team_membership + context.fail!(error: 'Owner cannot be deleted.') if context.team_membership.owner? + end + + def destroy_team_membership + context.team_membership.destroy! + end + + def clean_up_transfer_requests + team = context.team_membership.team + team.reset_transfer! if team.transfer_owner_id == context.team_membership.user_id + end +end diff --git a/api/app/interactors/export_project.rb b/api/app/interactors/export_project.rb new file mode 100644 index 0000000..f6e8cf5 --- /dev/null +++ b/api/app/interactors/export_project.rb @@ -0,0 +1,23 @@ +class ExportProject + include Interactor + + def call + ActiveRecord::Base.transaction do + check_pending_export + create_export_instance + initiate_export + end + end + + def check_pending_export + context.fail!(error: 'You have a pending export.') if context.project.exports.pending.present? + end + + def create_export_instance + context.export = context.project.exports.create + end + + def initiate_export + ExportProjectPublisher.publish({ project_id: context.project.id, export_id: context.export.id }) + end +end diff --git a/api/app/interactors/import_project.rb b/api/app/interactors/import_project.rb new file mode 100644 index 0000000..e45c189 --- /dev/null +++ b/api/app/interactors/import_project.rb @@ -0,0 +1,23 @@ +class ImportProject + include Interactor + + def call + ActiveRecord::Base.transaction do + check_pending_restore + initialize_restore + initiate_restore_worker + end + end + + def check_pending_restore + context.fail!(error: 'Previous import is running. It may take a while to finish.') if context.project.restores.pending.present? + end + + def initialize_restore + context.restore = context.project.restores.create(url: context.params[:url]) + end + + def initiate_restore_worker + ImportProjectPublisher.publish({ project_id: context.project.id, restore_id: context.restore.id }) + end +end diff --git a/api/app/interactors/perform_export.rb b/api/app/interactors/perform_export.rb new file mode 100644 index 0000000..64b10c2 --- /dev/null +++ b/api/app/interactors/perform_export.rb @@ -0,0 +1,224 @@ +class PerformExport # rubocop:disable Metrics/ClassLength + include Interactor + + MAIN_FOLDER_PATH = 'tmp/exports'.freeze + FILE_NAME = 'data.json'.freeze + MODELS = [Entity, Field, Record, Property].freeze + + def call + ActiveRecord::Base.transaction do + set_default + collect_entities + collect_resources + collect_fields + collect_field_hierarchies + collect_records + collect_properties + collect_property_hierarchies + write_to_temp_file + upload_temp_file + delete_temp_file + end + end + + def set_default + @mapping = MODELS.each_with_object({}) { |model, obj| obj[model.name.downcase.to_sym] = {} } + @assets = context.project.assets + @data = {} + end + + def collect_entities + @current_model = Entity + @entities = context.project.entities + @data[model_name] = segregrate_and_map(@entities) + end + + def collect_resources + @data[:resources] = context.project.resources.map do |resource| + { + name: resource.name, + url: resource.resolve_file, + size: resource.file.size + } + end + end + + def collect_fields + @current_model = Field + + sql = %( + SELECT fields.* FROM fields + INNER JOIN field_hierarchies ON fields.id = field_hierarchies.descendant_id + WHERE field_hierarchies.ancestor_id IN (SELECT id from fields where entity_id IN (#{@entities.ids.join(', ')})) + ORDER BY created_at; + ) + + @fields = @current_model.find_by_sql(sql) + @data[model_name] = segregrate_and_map(@fields) + end + + def collect_field_hierarchies + ids = @fields.pluck(:id).join(', ') + + sql = %( + SELECT * from field_hierarchies + WHERE ancestor_id IN (#{ids}) + OR descendant_id IN (#{ids}); + ) + + field_hierarchies = ActiveRecord::Base.connection.exec_query(sql).to_a + + @data["#{model_name}_hierarchies"] = field_hierarchies.map { |fh| process_hierarchy_for(fh) } + end + + def collect_records + @current_model = Record + @records = Record.where(entity_id: @entities.ids) + @data[model_name] = segregrate_and_map(@records) + end + + def collect_properties + @current_model = Property + sql = %( + SELECT properties.* FROM properties + INNER JOIN property_hierarchies ON properties.id = property_hierarchies.descendant_id + WHERE property_hierarchies.ancestor_id IN (SELECT id from properties where record_id IN (#{@records.ids.join(', ')})) + ) + + @properties = ActiveRecord::Base.connection.exec_query(sql).to_a + @data[model_name] = segregrate_and_map(@properties) + end + + def collect_property_hierarchies + ids = @properties.map { |p| p['id'] }.join(', ') + + sql = %( + SELECT * from property_hierarchies + WHERE ancestor_id IN (#{ids}) + OR descendant_id IN (#{ids}) + ) + + property_hierarchies = ActiveRecord::Base.connection.exec_query(sql).to_a + + @data["#{model_name}_hierarchies"] = property_hierarchies.map { |fh| process_hierarchy_for(fh) } + end + + def write_to_temp_file + FileUtils.mkdir_p(folder_path) unless Dir.exist?(folder_path) + File.open(file_path, 'w') do |f| + f.write(@data.to_json) + end + end + + def upload_temp_file + if context.skip_upload + context.path = file_path + else + context.export.file = File.open(file_path, 'rb') + context.export.completed! + end + end + + def delete_temp_file + File.delete(file_path) if File.exist?(file_path) && !context.skip_upload + end + + # Helper methods + + def file_path + "#{folder_path}/#{FILE_NAME}" + end + + def folder_path + "#{MAIN_FOLDER_PATH}/#{context.project.id}" + end + + def process_hierarchy_for(node) + { + ancestor_id: mapping[node['ancestor_id']], + descendant_id: mapping[node['descendant_id']], + generations: node['generations'] + } + end + + def segregate(records) + dataset = [] + level = 0 + cloned_records = records.clone.to_a + + until cloned_records.empty? + if level == 0 + dataset[level] = select_from_array(cloned_records) { |r| (r[:parent_id] || r['parent_id']).nil? } + else + parent_ids = dataset[level - 1].map { |r| r[:id] || r['id'] } + dataset[level] = select_from_array(cloned_records) { |r| parent_ids.include?(r[:parent_id] || r['parent_id']) } + end + + level += 1 + end + + dataset + end + + def segregrate_and_map(records) + segregate(records).map { |set| process(set) } + end + + def process(records) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity + records.map do |record| + normalized_record = record.respond_to?(:attributes) ? record.attributes : record + attributes = normalized_record.except('created_at', 'updated_at') + attributes = attributes.except('id') if context.include_id.blank? + @mapping[model_name][normalized_record['id']] = normalized_record['uid'] + + associations.each do |association_name, association_model_name| + association = "#{association_name}_id" # property_id, linked_record_id etc. + + if association_name == :project + attributes[association] = nil + elsif association_name == :asset && attributes['asset_id'].present? + attributes['asset_id'] = extract_asset_for(attributes) + elsif attributes[association].present? + attributes[association] = resolve_uid(association_model_name, attributes[association]) + end + end + + attributes + end + end + + def extract_asset_for(record) + asset = @assets.find { |a| a.id == record['asset_id'] } + + { + name: asset&.name, + url: asset&.resolve_original_file + } + end + + def select_from_array(array, &block) + temp = array.select(&block) + array.reject!(&block) + temp + end + + def resolve_uid(name, id) + @mapping[name][id] if @mapping[name].present? + end + + def associations + model.reflect_on_all_associations(:belongs_to).inject({}) { |obj, a| obj[a.name] = a.class_name.downcase.to_sym; obj } # rubocop:disable Style/EachWithObject,Style/Semicolon + end + + def model + @current_model + end + + def mapping + @mapping[model_name] + end + + def model_name + model.name.downcase.to_sym + end +end diff --git a/api/app/interactors/perform_restore.rb b/api/app/interactors/perform_restore.rb new file mode 100644 index 0000000..5c08dbf --- /dev/null +++ b/api/app/interactors/perform_restore.rb @@ -0,0 +1,339 @@ +# rubocop:disable Metrics/ClassLength +require 'open-uri' + +class PerformRestore + include Interactor + + MODELS = [Entity, Field, Record, Property].freeze + MODELS_AS_TREE = [:field, :property].freeze + MODEL_PRIMARY_BELONGS_TO = { + entity: :project, + field: :entity, + property: :record, + record: :entity + }.freeze + OPERATIONS = [:create, :update, :destroy].freeze + + def call + ActiveRecord::Base.transaction do + setup + process + restore_resources + destroy_temp_file + update_restore_complete + end + end + + def setup + # Initialize mapping + @mapping = MODELS.each_with_object({}) { |m, o| o[m.name.downcase.to_sym] = {} } + @local_data = {} + @local_export = nil + + open(context.restore.url) { |io| @raw_data = io.read } + @remote_data = JSON.parse(@raw_data, symbolize_names: true) + end + + def process + if project_empty? + MODELS.each do |model| + set_current_model(model) + + data = @remote_data[current_model_name] + create_records(data) + + if MODELS_AS_TREE.include? current_model_name + hierarchy_data = @remote_data[current_hierarchy] + create_hierarchies(hierarchy_data) + end + end + else + @local_export = PerformExport.call!(project: context.project, skip_upload: true, include_id: true) + @local_data = JSON.parse(File.read(@local_export.path), symbolize_names: true) + + MODELS.each do |model| + set_current_model(model) + + initialize_uid_mapping + + data = compose_diff( + @local_data[current_model_name], + @remote_data[current_model_name] + ) + + if MODELS_AS_TREE.include? current_model_name + hierarchy_data = compose_hierarchy_diff( + @local_data[current_hierarchy], + @remote_data[current_hierarchy] + ) + end + + OPERATIONS.each do |operation| + public_send("#{operation}_records", data[operation]) + public_send("#{operation}_hierarchies", hierarchy_data[operation]) if MODELS_AS_TREE.include? current_model_name + end + end + end + end + + def restore_resources + diff = Diff.perform(@local_data[:resources], @remote_data[:resources], identifier: :name, ignore: [[:url]]) + resources = context.project.resources + + diff[:create].each do |resource| + resources.create!( + project: context.project, # if project not passed explicitly, project_id becomes nil in generate_location (ResourceFileUploader) + name: resource[:name], + file_remote_url: resource[:url] + ) + end + + diff[:destroy].each do |resource| + resources.find_by(name: resource[:name])&.destroy + end + + diff[:update].each do |resource| + resolved_resource = resources.find_by(name: resource[:name]) + resolved_resource&.update(file_remote_url: resource[:url]) + end + end + + def destroy_temp_file + path = @local_export&.path + + return if path.blank? + + File.delete(path) if File.exist?(path) + end + + def update_restore_complete + context.restore.completed! + end + + def create_records(data) + data.each do |dataset| + next if dataset.empty? + + import_and_sync_records_for(dataset) + end + end + + def update_records(data) + data.each do |dataset| + next if dataset.empty? + + sorted_dataset = map_references(dataset).sort_by { |c| c[:uid] } + loaded_records = fetch_from_db(sorted_dataset).order(:uid) + + loaded_records.zip(sorted_dataset).each { |r, v| r.update(v) } + end + end + + def destroy_records(data) + data.each do |dataset| + next if dataset.empty? + + dataset.each { |r| @mapping[current_model_name][r[:uid]] = r[:id] } + ids = dataset.collect { |d| d[:id] } + + current_model.where(id: ids).destroy_all + end + end + + def create_hierarchies(data) + query = hierarchies_insertion_query(data) + + ActiveRecord::Base.connection.execute(query) if query.present? + end + + def update_hierarchies(data) + # No op + end + + def destroy_hierarchies(data) + query = hierarchies_deletion_query(data) + + ActiveRecord::Base.connection.execute(query) if query.present? + end + + def compose_diff(local_data, remote_data) + diff = { + create: [], + update: [], + destroy: [] + } + + i = 0 + + # Refactor using zip? + while i < local_data.length && i < remote_data.length + chunk_diff = Diff.perform(local_data[i], remote_data[i], ignore: [[:asset_id, :url], [:id], [:position]]) + + diff[:create] << chunk_diff[:create] + diff[:update] << chunk_diff[:update] + diff[:destroy] << chunk_diff[:destroy] + + i += 1 + end + + diff[:destroy] << local_data[i..local_data.length] if i != local_data.length + diff[:create] << remote_data[i..remote_data.length] if i != remote_data.length + diff + end + + def compose_hierarchy_diff(local_data, remote_data) + { + create: remote_data - local_data, + destroy: local_data - remote_data + } + end + + def import_and_sync_records_for(dataset) + return if dataset.empty? + + records = map_references(dataset) + + current_model.import records + + reload_mapping_for(records) + end + + def map_references(records) + records.map do |record| + cloned_record = record.clone + # map all belongs_to relation + associations.each do |association_name, association_model_name| + association = "#{association_name}_id".to_sym # :property_id, :linked_record_id etc. + + if association_name == :project + cloned_record[association] = context.project.id + elsif association_name == :asset && record[:asset_id].present? + begin + cloned_record[:asset_id] = asset_for(record)&.id + rescue StandardError => e + Rails.logger.debug "Failed to add #{record}" + Rails.logger.debug e + end + elsif record[association].present? + cloned_record[association] = resolve_id(association_model_name, record[association]) + end + end + + cloned_record + end + end + + def asset_for(record) + context.project.assets.create!( + name: record[:asset_id][:name], + file_remote_url: record[:asset_id][:url] + ) + end + + def associations + current_model.reflect_on_all_associations(:belongs_to).each_with_object({}) do |association, obj| + obj[association.name] = association.class_name.downcase.to_sym + end + end + + def resolve_id(model_name, uid) + @mapping[model_name][uid] + end + + # This method is responsible for mapping saved records's uid to it's actual id after create. + # It fetches the recently created records(fetch_from_db) by collecting uid from the raw list of records(before saving), + # fetches the records from db and maps. + def reload_mapping_for(records) + (fetch_from_db(records) || []).each do |record| + @mapping[current_model_name][record.uid] = record.id + end + end + + def hierarchies(data) + (data || []).map do |hierarchy| + { + ancestor_id: resolve_id(current_model_name, hierarchy[:ancestor_id]), + descendant_id: resolve_id(current_model_name, hierarchy[:descendant_id]), + generations: hierarchy[:generations] + } + end + end + + def initialize_uid_mapping + @local_data[current_model_name].each do |dataset| + (dataset || []).each do |record| + @mapping[current_model_name][record[:uid]] = record[:id] + end + end + end + + # This method fetches the recently created records from the db. + # If Project B is being cloned from Project A then a particular record in both the project would have + # the same UID. So in order to fetch the records using their UID, there needs to another identifier that + # can uniquely identify a record (a composite key). For models having nested elements, there are two possibilities + # There could be a direct belongs to, like record_id for Property. But for nested elements, this record_id would be nil + # and it would be identified by parent_id. + def fetch_from_db(records) + return [] if records.empty? + + identifier = primary_identifier_of(records.first) + identifier_ids = records.collect { |r| r[identifier] }.uniq + records_uid = records.collect { |r| r[:uid] } + + current_model.where(uid: records_uid).where(identifier => identifier_ids) + end + + def primary_identifier_of(record) + is_child_generation = record[:parent_id].present? + primary_identifier = "#{MODEL_PRIMARY_BELONGS_TO[current_model_name]}_id".to_sym + secondary_identifier = :parent_id + + is_child_generation ? secondary_identifier : primary_identifier + end + + def hierarchies_insertion_query(data) + return '' if data.empty? + + records = hierarchies(data) + values = records.map { |r| "(#{r.values.join(', ')})" }.join(', ') + + %( + INSERT INTO #{current_hierarchy} (ancestor_id, descendant_id, generations) + VALUES #{values}; + ) + end + + def hierarchies_deletion_query(data) + return '' if data.empty? + + records = hierarchies(data) + values = records.map { |r| "(#{r.values.join(', ')})" }.join(', ') + + %( + DELETE FROM #{current_hierarchy} + WHERE (ancestor_id, descendant_id, generations) + IN (#{values}); + ) + end + + def current_model_name + current_model.name.downcase.to_sym + end + + def current_hierarchy + "#{current_model_name}_hierarchies".to_sym + end + + def current_model + @model + end + + def set_current_model(model) # rubocop:disable Naming/AccessorMethodName: Do not prefix writer method names with set_. + @model = model + end + + def project_empty? + context.project.entities.empty? + end +end +# rubocop:enable Metrics/ClassLength diff --git a/api/app/interactors/reject_transfer_request.rb b/api/app/interactors/reject_transfer_request.rb new file mode 100644 index 0000000..c08deac --- /dev/null +++ b/api/app/interactors/reject_transfer_request.rb @@ -0,0 +1,18 @@ +class RejectTransferRequest + include Interactor + + def call + ActiveRecord::Base.transaction do + verify_transfer_request + reject_transfer_request + end + end + + def verify_transfer_request + context.fail!(error: 'Your transfer link has either expired or been canceled.') if !context.team.transfer_requested? + end + + def reject_transfer_request + context.team.reset_transfer! + end +end diff --git a/api/app/interactors/revoke_key_pair.rb b/api/app/interactors/revoke_key_pair.rb new file mode 100644 index 0000000..eadf04d --- /dev/null +++ b/api/app/interactors/revoke_key_pair.rb @@ -0,0 +1,7 @@ +class RevokeKeyPair + include Interactor + + def call + context.key_pair.revoke! + end +end diff --git a/api/app/interactors/sort_fields.rb b/api/app/interactors/sort_fields.rb new file mode 100644 index 0000000..74333df --- /dev/null +++ b/api/app/interactors/sort_fields.rb @@ -0,0 +1,15 @@ +class SortFields + include Interactor + + def call + ActiveRecord::Base.transaction do + update_fields + end + end + + protected + + def update_fields + context.fields.each(&:save!) + end +end diff --git a/api/app/interactors/sso_callback.rb b/api/app/interactors/sso_callback.rb new file mode 100644 index 0000000..a8d3b94 --- /dev/null +++ b/api/app/interactors/sso_callback.rb @@ -0,0 +1,73 @@ +class SsoCallback + include Interactor + + def call + verify_payload + find_user + build_payload + end + + private + + def verify_payload + url_decoded_sso_payload = CGI.unescape context.params[:sso] + raise Exceptions::Unauthorized, 'Invalid Signature' unless valid_signature?(url_decoded_sso_payload) + + sso_payload = Base64.decode64(url_decoded_sso_payload) + context.query_params = extract_query_params(sso_payload) + + raise Exceptions::Unauthorized, 'Missing required params' unless required_params_present? + raise Exceptions::Unauthorized, 'Request Expired' if nonce_expired?(context.query_params[:nonce]) + end + + def valid_signature?(sso_payload) + ActiveSupport::SecurityUtils.secure_compare( + OpenSSL::HMAC.hexdigest( + 'SHA256', + ENV['CLAY_SSO_SECRET'], + sso_payload + ), + context.params[:sig] + ) + end + + def extract_query_params(sso_payload) + query_hash = Rack::Utils.parse_nested_query sso_payload + query_hash.transform_keys(&:to_sym) + end + + def nonce_expired?(nonce) + nonce = AuthNonce.find_by(nonce: nonce) + return true if nonce.blank? + + nonce.expired? + end + + def required_params_present? + context.query_params[:nonce].present? && context.query_params[:email].present? + end + + def find_user + user = User.find_by(email: context.query_params[:email]) + raise Exceptions::Unauthorized, 'User not found' unless user + + user.update external_uid: context.query_params[:external_uid].presence + context.user = user + end + + def build_payload + jti = AuthToken.generate_uniq_jti + aud = DeviceDetector.new(context.user_agent)&.device_type || 'desktop' + + context.sso_payload = { + token: JsonWebToken.encode( + user_id: context.user.id, + exp: (Time.current + 1.month).to_i, + jti: jti, + aud: aud + ) + } + + AuthToken.create!(jti: jti, aud: aud, user: context.user) + end +end diff --git a/api/app/interactors/sso_login.rb b/api/app/interactors/sso_login.rb new file mode 100644 index 0000000..e06320c --- /dev/null +++ b/api/app/interactors/sso_login.rb @@ -0,0 +1,34 @@ +class SsoLogin + include Interactor + + def call + generate_nonce + generate_sso + generate_sig + build_payload + end + + private + + def generate_nonce + nonce = AuthNonce.generate_uniq_nonce + context.nonce = AuthNonce.create!( + nonce: nonce, + expires_at: Time.current + AuthNonce::NONCE_EXPIRY_PERIOD + ).nonce + end + + def generate_sso + context.base64_encoded_sso = Base64.encode64("nonce=#{context.nonce}") + context.url_encoded_sso = CGI.escape context.base64_encoded_sso + end + + def generate_sig + context.sig = OpenSSL::HMAC.hexdigest('SHA256', ENV['CLAY_SSO_SECRET'], context.base64_encoded_sso) + end + + def build_payload + sso_url = "#{ENV['CLAY_SSO_URL']}?sso=#{context.url_encoded_sso}&sig=#{context.sig}" + context.sso_payload = { sso_url: sso_url } + end +end diff --git a/api/app/interactors/sso_logout.rb b/api/app/interactors/sso_logout.rb new file mode 100644 index 0000000..dca00ce --- /dev/null +++ b/api/app/interactors/sso_logout.rb @@ -0,0 +1,8 @@ +class SsoLogout + include Interactor + + def call + jti = context.decoded_token['jti'] + context.response = context.current_user.auth_tokens.where(jti: jti).delete_all != 0 + end +end diff --git a/api/app/interactors/update_asset.rb b/api/app/interactors/update_asset.rb new file mode 100644 index 0000000..2cf8068 --- /dev/null +++ b/api/app/interactors/update_asset.rb @@ -0,0 +1,7 @@ +class UpdateAsset + include Interactor + + def call + context.asset.update!(context.params) + end +end diff --git a/api/app/interactors/update_entity.rb b/api/app/interactors/update_entity.rb new file mode 100644 index 0000000..e32c81e --- /dev/null +++ b/api/app/interactors/update_entity.rb @@ -0,0 +1,7 @@ +class UpdateEntity + include Interactor + + def call + context.entity.update!(context.params) + end +end diff --git a/api/app/interactors/update_field.rb b/api/app/interactors/update_field.rb new file mode 100644 index 0000000..66d45a5 --- /dev/null +++ b/api/app/interactors/update_field.rb @@ -0,0 +1,56 @@ +class UpdateField + include Interactor + + def call + ActiveRecord::Base.transaction do + process + update_field + end + end + + protected + + def process + context.field_attributes = process_field(context.field, context.params) + end + + def update_field + context.field.update!(context.field_attributes) + end + + def process_field(field, params) + field_params = params.except(:children) + + children_attributes = process_children(field, params) + field_params[:id] = field.id if field.present? + field_params[:children_attributes] = children_attributes if children_attributes.present? + + field_params + end + + def process_children(parent, params) + children = params[:children] + + if parent&.array? + sub_parent = parent.children.first + sub_parent_attributes = { + name: "#{params[:name]}_item", + label: params[:label].singularize, + data_type: params[:element_type], + referenced_entity_id: params[:referenced_entity_id] + } + + sub_parent_attributes[:id] = sub_parent.id if sub_parent.present? + sub_parent_attributes[:children_attributes] = process_children(sub_parent, params) if children.present? + + [sub_parent_attributes] + else + (children || []).map do |child| + field = parent&.children&.find_by(id: child[:id]) + field = Field.new(child.except(:children)) if field.blank? + + process_field(field, child) + end + end + end +end diff --git a/api/app/interactors/update_project.rb b/api/app/interactors/update_project.rb new file mode 100644 index 0000000..9a4617b --- /dev/null +++ b/api/app/interactors/update_project.rb @@ -0,0 +1,7 @@ +class UpdateProject + include Interactor + + def call + context.project.update!(context.params) + end +end diff --git a/api/app/interactors/update_record.rb b/api/app/interactors/update_record.rb new file mode 100644 index 0000000..4d6512b --- /dev/null +++ b/api/app/interactors/update_record.rb @@ -0,0 +1,109 @@ +class UpdateRecord + include Interactor + + def call + ActiveRecord::Base.transaction do + process + update_record + end + end + + protected + + def process + context.properties_attributes = (context.params[:traits] || {}).map do |field_name, value| + field = context.record.entity.fields.find_by(name: field_name) + property = (context.record.properties || []).find { |p| p.field_id == field.id } + + process_value(field, property, value, context.params[:position]) + end + end + + def update_record + context.record.update!( + properties_attributes: context.properties_attributes + ) + end + + def process_value(field, property, input_value, position = 0) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + attribute = {} + + if field.array? + attribute = { children_attributes: process_array(field, property, input_value) } + elsif field.key_value? + attribute = { children_attributes: process_hash(field, property, input_value) } + elsif field.reference? + attribute = { linked_record: process_reference(input_value) } + elsif field.image? || field.file? + attribute = { asset: process_asset(input_value) } if !URL.valid?(input_value) + else + attribute = { value: input_value } + end + + if property.present? + attribute[:id] = property.id + else + attribute[:field] = field + end + + attribute[:position] = position if position.present? + + attribute + end + + def process_array(field, property, input_values) # rubocop:disable Metrics/CyclomaticComplexity + if property.present? + removed_records = property.children.ids - input_values.pluck(:id).map(&:to_i) + Property.destroy(removed_records) if removed_records.present? + end + + return [] if !input_values.is_a? Array + + sub_field = field.children.first + + processed_values = input_values.map do |input_value| + next if input_value.except(:position).blank? + + child_property = property.children.find_by(id: input_value[:id]) if property.present? + + value = sub_field.reference? ? input_value : input_value[:value] + process_value(sub_field, child_property, value, input_value[:position]) + end + + processed_values.compact + end + + def process_hash(field, property, input_values) + return [] if !input_values.is_a? Hash + + input_values.map do |field_name, value| + child_field = field.children.find_by(name: field_name) + child_property = (property&.children || []).find { |p| p.field_id == child_field.id } + + process_value(child_field, child_property, value) + end + end + + def process_reference(input_value) + linked_record = Record.find_by(id: input_value[:id]) if input_value[:id].present? + + if input_value&.is_a?(Hash) && input_value[:traits].present? + if linked_record.present? + linked_record = UpdateRecord.call!(params: input_value, record: linked_record).record + else + entity = Entity.find_by(id: input_value[:entity_id]) + linked_record = CreateRecord.call!(params: input_value, entity: entity).record + end + end + + linked_record + end + + def process_asset(value) + return Asset.find(value[:id]) if value.is_a?(Hash) && value[:id].present? + + return if !value.respond_to?(:content_type) + + context.record.entity.project.assets.create!(name: value.original_filename, file: value) + end +end diff --git a/api/app/interactors/update_resource.rb b/api/app/interactors/update_resource.rb new file mode 100644 index 0000000..3db7928 --- /dev/null +++ b/api/app/interactors/update_resource.rb @@ -0,0 +1,7 @@ +class UpdateResource + include Interactor + + def call + context.resource.update!(context.params) + end +end diff --git a/api/app/interactors/update_team.rb b/api/app/interactors/update_team.rb new file mode 100644 index 0000000..a338d3f --- /dev/null +++ b/api/app/interactors/update_team.rb @@ -0,0 +1,7 @@ +class UpdateTeam + include Interactor + + def call + context.team.update!(context.params) + end +end diff --git a/api/app/interactors/update_team_membership.rb b/api/app/interactors/update_team_membership.rb new file mode 100644 index 0000000..690a415 --- /dev/null +++ b/api/app/interactors/update_team_membership.rb @@ -0,0 +1,9 @@ +class UpdateTeamMembership + include Interactor + + def call + context.fail!(error: 'Role cannot be owner.') if context.params[:role] == 'owner' + + context.team_membership.update!(context.params) + end +end diff --git a/api/app/interactors/update_user.rb b/api/app/interactors/update_user.rb new file mode 100644 index 0000000..687fbc3 --- /dev/null +++ b/api/app/interactors/update_user.rb @@ -0,0 +1,7 @@ +class UpdateUser + include Interactor + + def call + context.user.update!(context.params.slice(:first_name, :last_name, :profile_picture)) + end +end diff --git a/api/app/jobs/application_job.rb b/api/app/jobs/application_job.rb new file mode 100755 index 0000000..a009ace --- /dev/null +++ b/api/app/jobs/application_job.rb @@ -0,0 +1,2 @@ +class ApplicationJob < ActiveJob::Base +end diff --git a/api/app/mailers/application_mailer.rb b/api/app/mailers/application_mailer.rb new file mode 100755 index 0000000..286b223 --- /dev/null +++ b/api/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: 'from@example.com' + layout 'mailer' +end diff --git a/api/app/models/application_record.rb b/api/app/models/application_record.rb new file mode 100755 index 0000000..10a4cba --- /dev/null +++ b/api/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true +end diff --git a/api/app/models/asset.rb b/api/app/models/asset.rb new file mode 100644 index 0000000..349c852 --- /dev/null +++ b/api/app/models/asset.rb @@ -0,0 +1,35 @@ +class Asset < ApplicationRecord + serialize :file_data, JSON + + include AssetFileUploader::Attachment.new(:file) + + belongs_to :project + + has_many :properties, dependent: :nullify + + validates :name, presence: true + validates :file, presence: true + + def metadata + file&.metadata + end + + def resolve_url_for(field) + return resolve_original_file if (field.settings && field.settings['versions']).blank? + + versions = { original: file_url(options) } + field.settings['versions'].each do |version| + versions[version['name']] = file_url(version['name'], options) + end + + versions + end + + def resolve_original_file + file_url(options) + end + + def options + { public: true } + end +end diff --git a/api/app/models/auth_nonce.rb b/api/app/models/auth_nonce.rb new file mode 100644 index 0000000..b6b9720 --- /dev/null +++ b/api/app/models/auth_nonce.rb @@ -0,0 +1,18 @@ +class AuthNonce < ApplicationRecord + validates :nonce, presence: true, uniqueness: true + validates :expires_at, presence: true + + NONCE_EXPIRY_PERIOD = 5.minutes + + def expired? + expires_at < Time.current + end + + def self.generate_uniq_nonce + loop do + key = SecureRandom.hex(32) + nonce = ActionController::HttpAuthentication::Digest.nonce(key) + break nonce unless exists?(nonce: nonce) + end + end +end diff --git a/api/app/models/auth_token.rb b/api/app/models/auth_token.rb new file mode 100644 index 0000000..0a076e0 --- /dev/null +++ b/api/app/models/auth_token.rb @@ -0,0 +1,18 @@ +class AuthToken < ApplicationRecord + validates :jti, presence: true, uniqueness: true + validates :aud, presence: true + + belongs_to :user + + def self.revoked?(user: nil, jti: nil) + !user.auth_tokens.exists?(jti: jti) + end + + def self.generate_uniq_jti + loop do + jti_raw = [ENV['HMAC_SECRET'], Time.current.to_i].join(':').to_s + jti = Digest::MD5.hexdigest(jti_raw) + break jti unless exists?(jti: jti) + end + end +end diff --git a/api/app/models/concerns/nested_fetchable.rb b/api/app/models/concerns/nested_fetchable.rb new file mode 100644 index 0000000..7b1f3b6 --- /dev/null +++ b/api/app/models/concerns/nested_fetchable.rb @@ -0,0 +1,23 @@ +module NestedFetchable + extend ActiveSupport::Concern + + class_methods do + def has_nested(table) # rubocop:disable Naming/PredicateName: Rename has_nested to nested?. + method_name = "nested_#{table}" + model = table.to_s.classify.constantize + hierarchy_table = "#{table.to_s.singularize}_hierarchies" + parent_foreign_key = "#{table_name.singularize}_id" + + define_method(method_name) do + query = <<-SQL.strip_heredoc + SELECT #{table}.* FROM #{table} + INNER JOIN #{hierarchy_table} ON #{table}.id = #{hierarchy_table}.descendant_id + WHERE #{hierarchy_table}.ancestor_id IN (SELECT id FROM #{table} WHERE #{parent_foreign_key} = #{id}) + ORDER BY created_at; + SQL + + model.find_by_sql(query) + end + end + end +end diff --git a/api/app/models/concerns/tokenable.rb b/api/app/models/concerns/tokenable.rb new file mode 100755 index 0000000..1163b8b --- /dev/null +++ b/api/app/models/concerns/tokenable.rb @@ -0,0 +1,39 @@ +module Tokenable + extend ActiveSupport::Concern + + class_methods do + def has_token(prefix = nil, digest_attribute: nil, token_attribute: nil) # rubocop:disable Naming/PredicateName, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + prefix = "#{prefix}_" if prefix + digest_attribute ||= "#{prefix}digest".to_sym + token_attribute ||= "#{prefix}token".to_sym + + define_singleton_method("generate_#{digest_attribute}") do + loop do + # We use base58 instead of hex, keeping the behavior similar to has_secure_token. + # However, this could be an issue if we ever shift from PostgreSQL to MySQL: + # https://github.com/rails/rails/issues/20133 + digest = SecureRandom.base58(24) + break digest unless exists?(digest_attribute => digest) + end + end + + define_singleton_method("find_with_#{token_attribute}") do |token| + id, digest = Token.decode(token) + return if !id || !digest + + record = find_by(id: id) + return if !record + + record_digest = record.send(digest_attribute) + return if !record_digest || !ActiveSupport::SecurityUtils.secure_compare(record_digest, digest) + + record + end + + define_method(token_attribute) do + digest = send(digest_attribute) + Token.encode(id, digest) if digest.present? + end + end + end +end diff --git a/api/app/models/concerns/transferable.rb b/api/app/models/concerns/transferable.rb new file mode 100644 index 0000000..41dd221 --- /dev/null +++ b/api/app/models/concerns/transferable.rb @@ -0,0 +1,35 @@ +module Transferable + extend ActiveSupport::Concern + + include Tokenable + + TRANSFER_EXPIRY_PERIOD = 3.days + + included do + has_token :transfer + end + + def request_transfer_to!(user) + self.transfer_digest = self.class.generate_transfer_digest + self.transfer_generated_at = Time.zone.now + self.transfer_owner = user + + save! + end + + def reset_transfer! + self.transfer_digest = nil + self.transfer_generated_at = nil + self.transfer_owner = nil + + save! + end + + def transfer_expired? + transfer_generated_at.present? && (Time.zone.now - transfer_generated_at > TRANSFER_EXPIRY_PERIOD) + end + + def transfer_requested? + transfer_owner.present? && !transfer_expired? + end +end diff --git a/api/app/models/concerns/uid.rb b/api/app/models/concerns/uid.rb new file mode 100644 index 0000000..2502dde --- /dev/null +++ b/api/app/models/concerns/uid.rb @@ -0,0 +1,15 @@ +module Uid + extend ActiveSupport::Concern + + included do + validates :uid, presence: true + + after_initialize :set_uid, if: :new_record? + end + + protected + + def set_uid + self.uid ||= SecureRandom.uuid + end +end diff --git a/api/app/models/entity.rb b/api/app/models/entity.rb new file mode 100644 index 0000000..de5a974 --- /dev/null +++ b/api/app/models/entity.rb @@ -0,0 +1,17 @@ +class Entity < ApplicationRecord + include NestedFetchable + include Uid + + belongs_to :parent, class_name: 'Entity', optional: true + belongs_to :project + + has_many :fields, dependent: :destroy + has_many :relationships, dependent: :destroy + has_many :records, dependent: :destroy + has_many :referenced_fields, class_name: 'Field', foreign_key: :referenced_entity_id, inverse_of: :referenced_entity, dependent: :nullify + + validates :label, presence: true + validates :name, presence: true + + has_nested :fields +end diff --git a/api/app/models/export.rb b/api/app/models/export.rb new file mode 100644 index 0000000..e1d6bb0 --- /dev/null +++ b/api/app/models/export.rb @@ -0,0 +1,11 @@ +class Export < ApplicationRecord + serialize :file_data, JSON + + include ExportFileUploader::Attachment.new(:file) + + enum status: { pending: 0, completed: 1, failed: 2 } + + belongs_to :project + + validates :status, presence: true +end diff --git a/api/app/models/field.rb b/api/app/models/field.rb new file mode 100644 index 0000000..b49c869 --- /dev/null +++ b/api/app/models/field.rb @@ -0,0 +1,35 @@ +class Field < ApplicationRecord + include Uid + + serialize :validations, JSON + serialize :settings, JSON + + has_closure_tree order: 'position', numeric_order: true, dependent: :destroy + + enum data_type: { single_line_text: 0, multiple_line_text: 1, number: 2, decimal: 3, boolean: 4, image: 5, key_value: 6, reference: 7, array: 8, color: 9, file: 10 } + enum element_type: Field.data_types.except(:array), _prefix: :element + + belongs_to :entity, optional: true + belongs_to :referenced_entity, class_name: 'Entity', optional: true + + has_many :properties, dependent: :destroy + has_many :relationships, dependent: :destroy + + validates :data_type, presence: true + validates :label, presence: true + validates :name, presence: true + validates :position, presence: true + + after_initialize :set_defaults + + accepts_nested_attributes_for :children + + protected + + def set_defaults + return unless new_record? + + self.validations ||= {} + self.settings ||= {} + end +end diff --git a/api/app/models/key_pair.rb b/api/app/models/key_pair.rb new file mode 100644 index 0000000..66bf413 --- /dev/null +++ b/api/app/models/key_pair.rb @@ -0,0 +1,31 @@ +class KeyPair < ApplicationRecord + PUBLIC_KEY_LENGTH = 24 + + belongs_to :project + + validates :public_key, presence: true, uniqueness: true + + before_validation :set_keys, on: :create + + def self.generate_public_key + generate_key(:public_key, PUBLIC_KEY_LENGTH) + end + + private_class_method def self.generate_key(key_attribute, key_length) + loop do + digest = SecureRandom.base58(key_length) + break digest unless exists?(key_attribute => digest) + end + end + + def revoke! + self.expires_at ||= Time.current + save! + end + + protected + + def set_keys + self.public_key ||= self.class.generate_public_key + end +end diff --git a/api/app/models/locale.rb b/api/app/models/locale.rb new file mode 100644 index 0000000..d73b970 --- /dev/null +++ b/api/app/models/locale.rb @@ -0,0 +1,5 @@ +class Locale < ApplicationRecord + validates :language, presence: true + + belongs_to :project +end diff --git a/api/app/models/project.rb b/api/app/models/project.rb new file mode 100644 index 0000000..bf96b1d --- /dev/null +++ b/api/app/models/project.rb @@ -0,0 +1,16 @@ +class Project < ApplicationRecord + include Uid + + belongs_to :team + + has_many :assets, dependent: :destroy + has_many :entities, dependent: :destroy + has_many :exports, dependent: :destroy + has_many :key_pairs, dependent: :destroy + has_many :locales, dependent: :destroy + has_many :restores, dependent: :destroy + has_many :resources, dependent: :destroy + + validates :name, presence: true + validates :uid, uniqueness: true +end diff --git a/api/app/models/property.rb b/api/app/models/property.rb new file mode 100644 index 0000000..b68f225 --- /dev/null +++ b/api/app/models/property.rb @@ -0,0 +1,19 @@ +class Property < ApplicationRecord + include Uid + + has_closure_tree order: 'position', numeric_order: true, dependent: :destroy + + belongs_to :record, optional: true + belongs_to :field + belongs_to :asset, optional: true + belongs_to :linked_record, class_name: 'Record', optional: true + + accepts_nested_attributes_for :children + + # To clone record and its properties + amoeba do + enable + set uid: SecureRandom.uuid + clone [:parent] + end +end diff --git a/api/app/models/record.rb b/api/app/models/record.rb new file mode 100644 index 0000000..11d81fb --- /dev/null +++ b/api/app/models/record.rb @@ -0,0 +1,40 @@ +class Record < ApplicationRecord + include NestedFetchable + include Uid + + belongs_to :entity + + has_many :properties, dependent: :destroy + has_many :linked_properties, class_name: 'Property', foreign_key: :linked_record_id, inverse_of: :linked_record, dependent: :destroy + + has_nested :properties + + accepts_nested_attributes_for :properties + + after_destroy -> { Property.rebuild! } + + # To clone record and its properties + amoeba do + enable + set uid: SecureRandom.uuid + end + + def convert_to_json(key_type = nil) + key_type ||= :camelize + + json = slice(:id, :created_at, :updated_at) + json = json.merge(entity_name: entity.name) + + entity_fields = entity.fields + record_properties = properties.includes(:field).where(field: entity_fields.pluck(:id)) + + entity_fields.each do |field| + property = record_properties.find { |p| p.field_id == field.id } + + json[field.name] = property&.data + end + + json.deep_transform_keys! { |k| k.camelize(:lower) } if key_type.try(:to_sym) == :camelize + json + end +end diff --git a/api/app/models/relationship.rb b/api/app/models/relationship.rb new file mode 100644 index 0000000..02f031c --- /dev/null +++ b/api/app/models/relationship.rb @@ -0,0 +1,7 @@ +class Relationship < ApplicationRecord + belongs_to :entity + belongs_to :field + + belongs_to :linked_entity, class_name: 'Entity', foreign_key: :linked_entity_id, inverse_of: :relationships + belongs_to :linked_field, class_name: 'Field', foreign_key: :linked_field_id, inverse_of: :relationships +end diff --git a/api/app/models/resource.rb b/api/app/models/resource.rb new file mode 100644 index 0000000..fa5ab4e --- /dev/null +++ b/api/app/models/resource.rb @@ -0,0 +1,18 @@ +class Resource < ApplicationRecord + serialize :file_data, JSON + + include ResourceFileUploader::Attachment.new(:file) + + belongs_to :project + + validates :name, presence: true, uniqueness: { scope: :project } + validates :file, presence: true + + def resolve_file + file_url(options) + end + + def options + { public: true } + end +end diff --git a/api/app/models/restore.rb b/api/app/models/restore.rb new file mode 100644 index 0000000..129d471 --- /dev/null +++ b/api/app/models/restore.rb @@ -0,0 +1,8 @@ +class Restore < ApplicationRecord + enum status: { pending: 0, completed: 1, failed: 2 } + + belongs_to :project + + validates :status, presence: true + validates :url, presence: true +end diff --git a/api/app/models/team.rb b/api/app/models/team.rb new file mode 100644 index 0000000..f2ea381 --- /dev/null +++ b/api/app/models/team.rb @@ -0,0 +1,12 @@ +class Team < ApplicationRecord + include Transferable + + validates :name, presence: true + + belongs_to :transfer_owner, class_name: 'User', inverse_of: :transferable_teams, optional: true + + has_many :projects, dependent: :destroy + has_many :team_memberships, dependent: :destroy + + has_many :users, through: :team_memberships +end diff --git a/api/app/models/team_membership.rb b/api/app/models/team_membership.rb new file mode 100644 index 0000000..66a7339 --- /dev/null +++ b/api/app/models/team_membership.rb @@ -0,0 +1,29 @@ +class TeamMembership < ApplicationRecord + enum role: { editor: 0, developer: 1, manager: 2, owner: 3 } + + belongs_to :team + belongs_to :user + + validates :role, presence: true + validates :user, uniqueness: { scope: :team_id } + + validate :only_one_owner_role_per_team + + def atleast?(allowed_role) + allowed_role_level = self.class.roles[allowed_role.to_s] + raise "Unknown role: #{allowed_role}" if allowed_role_level.blank? + + current_role_level = self.class.roles[role.to_s] + + current_role_level >= allowed_role_level + end + + protected + + def only_one_owner_role_per_team + return if !role_changed?(to: 'owner') || team.blank? || user.blank? + + owner_membership = self.class.find_by(team: team, role: :owner) + errors.add(:role, 'cannot be owner as the team already has one') if owner_membership.present? && owner_membership.user != user + end +end diff --git a/api/app/models/user.rb b/api/app/models/user.rb new file mode 100644 index 0000000..ee5c37c --- /dev/null +++ b/api/app/models/user.rb @@ -0,0 +1,15 @@ +class User < ApplicationRecord + serialize :profile_picture_data, JSON + + include ProfilePictureUploader::Attachment.new(:profile_picture) + + validates :email, presence: true, email_format: true, uniqueness: { case_sensitive: false } + validates :first_name, presence: true + validates :last_name, presence: true + + has_many :auth_tokens, dependent: :destroy + has_many :team_memberships, dependent: :destroy + has_many :transferable_teams, class_name: 'Team', foreign_key: :transfer_owner, inverse_of: :transfer_owner, dependent: :nullify + + has_many :teams, through: :team_memberships +end diff --git a/api/app/policies/application_policy.rb b/api/app/policies/application_policy.rb new file mode 100755 index 0000000..b91c7f5 --- /dev/null +++ b/api/app/policies/application_policy.rb @@ -0,0 +1,53 @@ +class ApplicationPolicy + attr_reader :user, :record + + def initialize(user, record) + @user = user + @record = record + end + + def index? + false + end + + def show? + scope.where(id: record.id).exists? + end + + def create? + false + end + + def new? + create? + end + + def update? + false + end + + def edit? + update? + end + + def destroy? + false + end + + def scope + Pundit.policy_scope!(user, record.class) + end + + class Scope + attr_reader :user, :scope + + def initialize(user, scope) + @user = user + @scope = scope + end + + def resolve + scope + end + end +end diff --git a/api/app/policies/asset_policy.rb b/api/app/policies/asset_policy.rb new file mode 100644 index 0000000..edf0bb9 --- /dev/null +++ b/api/app/policies/asset_policy.rb @@ -0,0 +1,15 @@ +class AssetPolicy < BaseMemberPolicy + def update? + team_membership&.atleast?(:manager) + end + + def destroy? + team_membership&.atleast?(:manager) + end + + protected + + def team + @team ||= record.project.team + end +end diff --git a/api/app/policies/base_member_policy.rb b/api/app/policies/base_member_policy.rb new file mode 100644 index 0000000..2b355f2 --- /dev/null +++ b/api/app/policies/base_member_policy.rb @@ -0,0 +1,13 @@ +class BaseMemberPolicy < ApplicationPolicy + protected + + def team + raise 'must be overridden' + end + + def team_membership + return if user.blank? || team.blank? + + @team_membership ||= TeamMembership.find_by(user: user, team: team) + end +end diff --git a/api/app/policies/entity_policy.rb b/api/app/policies/entity_policy.rb new file mode 100644 index 0000000..fa490a0 --- /dev/null +++ b/api/app/policies/entity_policy.rb @@ -0,0 +1,29 @@ +class EntityPolicy < BaseMemberPolicy + def view? + team_membership&.atleast?(:developer) + end + + def update? + team_membership&.atleast?(:developer) + end + + def destroy? + team_membership&.atleast?(:developer) + end + + def create_field? + team_membership&.atleast?(:developer) + end + + def create_record? + team_membership&.atleast?(:editor) + end + + alias clone_record? create_record? + + protected + + def team + @team ||= record.project.team + end +end diff --git a/api/app/policies/field_policy.rb b/api/app/policies/field_policy.rb new file mode 100644 index 0000000..fc4635e --- /dev/null +++ b/api/app/policies/field_policy.rb @@ -0,0 +1,19 @@ +class FieldPolicy < BaseMemberPolicy + def view? + team_membership&.atleast?(:developer) + end + + def update? + team_membership&.atleast?(:developer) + end + + def destroy? + team_membership&.atleast?(:developer) + end + + protected + + def team + @team ||= record.entity.project.team + end +end diff --git a/api/app/policies/key_pair_policy.rb b/api/app/policies/key_pair_policy.rb new file mode 100644 index 0000000..23a43f4 --- /dev/null +++ b/api/app/policies/key_pair_policy.rb @@ -0,0 +1,11 @@ +class KeyPairPolicy < BaseMemberPolicy + def revoke? + team_membership&.atleast?(:developer) + end + + protected + + def team + @team ||= record.project.team + end +end diff --git a/api/app/policies/project_policy.rb b/api/app/policies/project_policy.rb new file mode 100644 index 0000000..ec31169 --- /dev/null +++ b/api/app/policies/project_policy.rb @@ -0,0 +1,63 @@ +class ProjectPolicy < BaseMemberPolicy + def view? + team_membership.present? + end + + def view_assets? + view? + end + + def view_resources? + view? + end + + def view_exports? + view? + end + + def view_imports? + view? + end + + def export_project? + team_membership&.atleast?(:manager) + end + + def import_project? + team_membership&.atleast?(:manager) + end + + def view_key_pairs? + team_membership&.atleast?(:developer) + end + + def view_entities? + team_membership&.atleast?(:developer) + end + + def create_asset? + team_membership&.atleast?(:editor) + end + + def create_resource? + team_membership&.atleast?(:editor) + end + + def create_key_pair? + team_membership&.atleast?(:developer) + end + + def create_entity? + team_membership&.atleast?(:developer) + end + + def update? + team_membership&.atleast?(:developer) + end + + protected + + def team + @team ||= record.team + end +end diff --git a/api/app/policies/record_policy.rb b/api/app/policies/record_policy.rb new file mode 100644 index 0000000..49b949b --- /dev/null +++ b/api/app/policies/record_policy.rb @@ -0,0 +1,19 @@ +class RecordPolicy < BaseMemberPolicy + def view? + team_membership&.atleast?(:editor) + end + + def update? + team_membership&.atleast?(:editor) + end + + def destroy? + team_membership&.atleast?(:editor) + end + + protected + + def team + @team ||= record.entity.project.team + end +end diff --git a/api/app/policies/resource_policy.rb b/api/app/policies/resource_policy.rb new file mode 100644 index 0000000..61ca1d3 --- /dev/null +++ b/api/app/policies/resource_policy.rb @@ -0,0 +1,15 @@ +class ResourcePolicy < BaseMemberPolicy + def update? + team_membership&.atleast?(:manager) + end + + def destroy? + team_membership&.atleast?(:manager) + end + + protected + + def team + @team ||= record.project.team + end +end diff --git a/api/app/policies/team_membership_policy.rb b/api/app/policies/team_membership_policy.rb new file mode 100644 index 0000000..743bc6c --- /dev/null +++ b/api/app/policies/team_membership_policy.rb @@ -0,0 +1,17 @@ +class TeamMembershipPolicy < BaseMemberPolicy + def update? + return false if record.owner? + + team_membership&.atleast?(:manager) + end + + def destroy? + update? + end + + protected + + def team + @team ||= record.team + end +end diff --git a/api/app/policies/team_policy.rb b/api/app/policies/team_policy.rb new file mode 100644 index 0000000..60b0fef --- /dev/null +++ b/api/app/policies/team_policy.rb @@ -0,0 +1,51 @@ +class TeamPolicy < BaseMemberPolicy + def view? + team_membership.present? + end + + def view_team_memberships? + view? + end + + def view_projects? + view? + end + + def update? + team_membership&.atleast?(:owner) + end + + def destroy? + update? + end + + def create_team_membership? + team_membership&.atleast?(:manager) + end + + def create_project? + team_membership&.atleast?(:manager) + end + + def create_transfer_request? + team_membership&.atleast?(:owner) + end + + def cancel_transfer_request? + team_membership&.atleast?(:owner) + end + + def accept_transfer_request? + team_membership.present? && user.id == team.transfer_owner_id + end + + def reject_transfer_request? + accept_transfer_request? + end + + protected + + def team + @team ||= record + end +end diff --git a/api/app/publishers/base_publisher.rb b/api/app/publishers/base_publisher.rb new file mode 100644 index 0000000..17eee31 --- /dev/null +++ b/api/app/publishers/base_publisher.rb @@ -0,0 +1,21 @@ +class BasePublisher + def self.publish(message = {}) + exchange.publish(message.to_json, routing_key: @queue) + end + + def self.channel + @channel ||= connection.create_channel + end + + def self.connection + @connection ||= Bunny.new(RabbitMq.connection_url).tap(&:start) + end + + def self.exchange + @exchange ||= channel.default_exchange + end + + def self.to_queue(name) + @queue = name + end +end diff --git a/api/app/publishers/export_project_publisher.rb b/api/app/publishers/export_project_publisher.rb new file mode 100644 index 0000000..3e17332 --- /dev/null +++ b/api/app/publishers/export_project_publisher.rb @@ -0,0 +1,3 @@ +class ExportProjectPublisher < BasePublisher + to_queue 'export_project' +end diff --git a/api/app/publishers/import_project_publisher.rb b/api/app/publishers/import_project_publisher.rb new file mode 100644 index 0000000..c11b6bc --- /dev/null +++ b/api/app/publishers/import_project_publisher.rb @@ -0,0 +1,3 @@ +class ImportProjectPublisher < BasePublisher + to_queue 'import_project' +end diff --git a/api/app/uploaders/asset_file_uploader.rb b/api/app/uploaders/asset_file_uploader.rb new file mode 100644 index 0000000..0f9ca47 --- /dev/null +++ b/api/app/uploaders/asset_file_uploader.rb @@ -0,0 +1,25 @@ +class AssetFileUploader < BaseUploader + plugin :add_metadata + plugin :remote_url, max_size: 20 * 1024 * 1024 + plugin :upload_options, + cache: ->(io, **) { { cache_control: "max-age=#{1.year}, s-maxage=#{1.year}" } }, + store: ->(io, **) { { acl: 'public-read' } } + + add_metadata do |io, context| + metadata = {} + mime_type = Shrine.determine_mime_type(io) + metadata[:extension] = Shrine.infer_extension(mime_type) + metadata + end + + Attacher.derivatives_processor do |original| + versions = {} + pipeline = ImageProcessing::MiniMagick.source(original) + settings = context[:record].properties.first&.field&.settings || {} + (settings['versions'] || []).each do |version| + versions[version['name'].to_sym] = pipeline.resize_to_fill!(version['width'], version['height'], gravity: 'Center') + end + + versions + end +end diff --git a/api/app/uploaders/base_uploader.rb b/api/app/uploaders/base_uploader.rb new file mode 100644 index 0000000..8f62d4d --- /dev/null +++ b/api/app/uploaders/base_uploader.rb @@ -0,0 +1,3 @@ +class BaseUploader < Shrine + plugin :pretty_location, namespace: '_' +end diff --git a/api/app/uploaders/export_file_uploader.rb b/api/app/uploaders/export_file_uploader.rb new file mode 100644 index 0000000..b744b35 --- /dev/null +++ b/api/app/uploaders/export_file_uploader.rb @@ -0,0 +1,4 @@ +class ExportFileUploader < BaseUploader + plugin :remote_url, max_size: 20 * 1024 * 1024 + plugin :upload_options, store: ->(io, **) { { acl: 'public-read' } } +end diff --git a/api/app/uploaders/image_uploader.rb b/api/app/uploaders/image_uploader.rb new file mode 100644 index 0000000..60358ad --- /dev/null +++ b/api/app/uploaders/image_uploader.rb @@ -0,0 +1,2 @@ +class ImageUploader < BaseUploader +end diff --git a/api/app/uploaders/profile_picture_uploader.rb b/api/app/uploaders/profile_picture_uploader.rb new file mode 100644 index 0000000..ac07bab --- /dev/null +++ b/api/app/uploaders/profile_picture_uploader.rb @@ -0,0 +1,16 @@ +class ProfilePictureUploader < ImageUploader + plugin :validation_helpers + + Attacher.validate do + validate_mime_type_inclusion %w[image/jpeg image/png] + end + + Attacher.derivatives_processor do |original| + versions = {} + pipeline = ImageProcessing::MiniMagick.source(original) + versions[:normal] = pipeline.resize_to_fill!(200, 200, gravity: 'Center') + versions[:thumbnail] = pipeline.resize_to_fill!(100, 100, gravity: 'Center') + + versions + end +end diff --git a/api/app/uploaders/resource_file_uploader.rb b/api/app/uploaders/resource_file_uploader.rb new file mode 100644 index 0000000..1830178 --- /dev/null +++ b/api/app/uploaders/resource_file_uploader.rb @@ -0,0 +1,21 @@ +class ResourceFileUploader < BaseUploader + BASE_FOLDER = 'resources'.freeze + + plugin :add_metadata + plugin :remote_url, max_size: 20 * 1024 * 1024 + plugin :upload_options, + cache: ->(io, **) { { cache_control: "max-age=#{1.year}, s-maxage=#{1.year}" } }, + store: ->(io, context) { { acl: 'public-read' } } + + add_metadata do |io, context| + metadata = {} + mime_type = Shrine.determine_mime_type(io) + metadata[:extension] = Shrine.infer_extension(mime_type) + + metadata + end + + def generate_location(io, record: nil, **context) # rubocop:disable Lint/UnusedMethodArgument + "#{BASE_FOLDER}/#{record.project.uid}/file/#{record.name}" + end +end diff --git a/api/app/validators/email_format_validator.rb b/api/app/validators/email_format_validator.rb new file mode 100755 index 0000000..2e86b44 --- /dev/null +++ b/api/app/validators/email_format_validator.rb @@ -0,0 +1,7 @@ +class EmailFormatValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + return if value.blank? || MailAddress.valid_email?(value) + + record.errors[attribute] << (options[:message] || 'is not a valid email') + end +end diff --git a/api/app/views/layouts/application.html.erb b/api/app/views/layouts/application.html.erb new file mode 100755 index 0000000..cfc4c82 --- /dev/null +++ b/api/app/views/layouts/application.html.erb @@ -0,0 +1,15 @@ + + + + Rails API + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %> + <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %> + + + + <%= yield %> + + diff --git a/api/app/views/layouts/mailer.html.erb b/api/app/views/layouts/mailer.html.erb new file mode 100755 index 0000000..cbd34d2 --- /dev/null +++ b/api/app/views/layouts/mailer.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + <%= yield %> + + diff --git a/api/app/views/layouts/mailer.text.erb b/api/app/views/layouts/mailer.text.erb new file mode 100755 index 0000000..37f0bdd --- /dev/null +++ b/api/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/api/app/workers/export_project_worker.rb b/api/app/workers/export_project_worker.rb new file mode 100644 index 0000000..b791daa --- /dev/null +++ b/api/app/workers/export_project_worker.rb @@ -0,0 +1,26 @@ +class ExportProjectWorker + include Sneakers::Worker + from_queue 'export_project' + + def work(msg) + obj = JSON.parse(msg, symbolize_names: true) + + project = Project.find(obj[:project_id]) + export = Export.find(obj[:export_id]) + + return ack! if export.completed? + + begin + PerformExport.call!(project: project, export: export) + rescue StandardError => e + export.failed! + + logger.info "Failed to export #{msg}" + logger.info e + + return reject! + end + + ack! + end +end diff --git a/api/app/workers/import_project_worker.rb b/api/app/workers/import_project_worker.rb new file mode 100644 index 0000000..a190960 --- /dev/null +++ b/api/app/workers/import_project_worker.rb @@ -0,0 +1,26 @@ +class ImportProjectWorker + include Sneakers::Worker + from_queue 'import_project' + + def work(msg) + obj = JSON.parse(msg, symbolize_names: true) + + project = Project.find(obj[:project_id]) + restore = Restore.find(obj[:restore_id]) + + return ack! if restore.completed? + + begin + PerformRestore.call!(project: project, restore: restore) + rescue StandardError => e + restore.failed! + + logger.info "Failed to import #{msg}" + logger.info e + + return reject! + end + + ack! + end +end diff --git a/api/bin/bundle b/api/bin/bundle new file mode 100755 index 0000000..f19acf5 --- /dev/null +++ b/api/bin/bundle @@ -0,0 +1,3 @@ +#!/usr/bin/env ruby +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) +load Gem.bin_path('bundler', 'bundle') diff --git a/api/bin/rails b/api/bin/rails new file mode 100755 index 0000000..5badb2f --- /dev/null +++ b/api/bin/rails @@ -0,0 +1,9 @@ +#!/usr/bin/env ruby +begin + load File.expand_path('../spring', __FILE__) +rescue LoadError => e + raise unless e.message.include?('spring') +end +APP_PATH = File.expand_path('../config/application', __dir__) +require_relative '../config/boot' +require 'rails/commands' diff --git a/api/bin/rake b/api/bin/rake new file mode 100755 index 0000000..d87d5f5 --- /dev/null +++ b/api/bin/rake @@ -0,0 +1,9 @@ +#!/usr/bin/env ruby +begin + load File.expand_path('../spring', __FILE__) +rescue LoadError => e + raise unless e.message.include?('spring') +end +require_relative '../config/boot' +require 'rake' +Rake.application.run diff --git a/api/bin/scripts/clay b/api/bin/scripts/clay new file mode 100755 index 0000000..edfc5b3 --- /dev/null +++ b/api/bin/scripts/clay @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +docker-compose exec clay bash \ No newline at end of file diff --git a/api/bin/scripts/setup-db-dev b/api/bin/scripts/setup-db-dev new file mode 100755 index 0000000..13e84e6 --- /dev/null +++ b/api/bin/scripts/setup-db-dev @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +docker-compose exec pgdb psql -U postgres -c 'CREATE DATABASE clay_cms_development' +docker-compose exec pgdb psql -U postgres -c 'CREATE DATABASE clay_cms_test' +docker-compose exec pgdb psql -U postgres -c 'CREATE DATABASE clay_cms_staging' +docker-compose exec pgdb psql -U postgres -c 'CREATE DATABASE clay_cms_production' + +docker-compose exec pgdb psql -U postgres -c "CREATE USER claycool with ENCRYPTED PASSWORD 'ABC12abc'" +docker-compose exec pgdb psql -U postgres -c 'GRANT ALL PRIVILEGES ON DATABASE clay_cms_development to claycool' +docker-compose exec pgdb psql -U postgres -c 'GRANT ALL PRIVILEGES ON DATABASE clay_cms_test to claycool' +docker-compose exec pgdb psql -U postgres -c 'GRANT ALL PRIVILEGES ON DATABASE clay_cms_staging to claycool' +docker-compose exec pgdb psql -U postgres -c 'GRANT ALL PRIVILEGES ON DATABASE clay_cms_production to claycool' \ No newline at end of file diff --git a/api/bin/scripts/start-app b/api/bin/scripts/start-app new file mode 100755 index 0000000..8edfd85 --- /dev/null +++ b/api/bin/scripts/start-app @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +echo "removing old pid's .." +rm -f tmp/pids/server.pid + +echo "checking bundle dependencies .." +bundle check || bundle install + +echo "boooting up .." +bundle exec rails s -p 3000 -b 0.0.0.0 -e development \ No newline at end of file diff --git a/api/bin/setup b/api/bin/setup new file mode 100755 index 0000000..94fd4d7 --- /dev/null +++ b/api/bin/setup @@ -0,0 +1,36 @@ +#!/usr/bin/env ruby +require 'fileutils' +include FileUtils + +# path to your application root. +APP_ROOT = File.expand_path('..', __dir__) + +def system!(*args) + system(*args) || abort("\n== Command #{args} failed ==") +end + +chdir APP_ROOT do + # This script is a starting point to setup your application. + # Add necessary setup steps to this file. + + puts '== Installing dependencies ==' + system! 'gem install bundler --conservative' + system('bundle check') || system!('bundle install') + + # Install JavaScript dependencies if using Yarn + # system('bin/yarn') + + # puts "\n== Copying sample files ==" + # unless File.exist?('config/database.yml') + # cp 'config/database.yml.sample', 'config/database.yml' + # end + + puts "\n== Preparing database ==" + system! 'bin/rails db:setup' + + puts "\n== Removing old logs and tempfiles ==" + system! 'bin/rails log:clear tmp:clear' + + puts "\n== Restarting application server ==" + system! 'bin/rails restart' +end diff --git a/api/bin/spring b/api/bin/spring new file mode 100755 index 0000000..d89ee49 --- /dev/null +++ b/api/bin/spring @@ -0,0 +1,17 @@ +#!/usr/bin/env ruby + +# This file loads Spring without using Bundler, in order to be fast. +# It gets overwritten when you run the `spring binstub` command. + +unless defined?(Spring) + require 'rubygems' + require 'bundler' + + lockfile = Bundler::LockfileParser.new(Bundler.default_lockfile.read) + spring = lockfile.specs.detect { |spec| spec.name == 'spring' } + if spring + Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path + gem 'spring', spring.version + require 'spring/binstub' + end +end diff --git a/api/bin/update b/api/bin/update new file mode 100755 index 0000000..58bfaed --- /dev/null +++ b/api/bin/update @@ -0,0 +1,31 @@ +#!/usr/bin/env ruby +require 'fileutils' +include FileUtils + +# path to your application root. +APP_ROOT = File.expand_path('..', __dir__) + +def system!(*args) + system(*args) || abort("\n== Command #{args} failed ==") +end + +chdir APP_ROOT do + # This script is a way to update your development environment automatically. + # Add necessary update steps to this file. + + puts '== Installing dependencies ==' + system! 'gem install bundler --conservative' + system('bundle check') || system!('bundle install') + + # Install JavaScript dependencies if using Yarn + # system('bin/yarn') + + puts "\n== Updating database ==" + system! 'bin/rails db:migrate' + + puts "\n== Removing old logs and tempfiles ==" + system! 'bin/rails log:clear tmp:clear' + + puts "\n== Restarting application server ==" + system! 'bin/rails restart' +end diff --git a/api/bin/yarn b/api/bin/yarn new file mode 100755 index 0000000..460dd56 --- /dev/null +++ b/api/bin/yarn @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +APP_ROOT = File.expand_path('..', __dir__) +Dir.chdir(APP_ROOT) do + begin + exec "yarnpkg", *ARGV + rescue Errno::ENOENT + $stderr.puts "Yarn executable was not detected in the system." + $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install" + exit 1 + end +end diff --git a/api/config.ru b/api/config.ru new file mode 100755 index 0000000..62f79ab --- /dev/null +++ b/api/config.ru @@ -0,0 +1,18 @@ +# This file is used by Rack-based servers to start the application. + +require_relative 'config/environment' + +require 'rack/cors' + +use Rack::Deflater + +use Rack::Cors do + allow do + origins '*' + resource '*', headers: :any, methods: [:get, :post, :put, :patch, :delete, :options, :head] + end +end + +use Rack::CanonicalHost, ENV['API_HOST'], force_ssl: ENV['FORCE_SSL'].present? + +run Rails.application diff --git a/api/config/application.rb b/api/config/application.rb new file mode 100755 index 0000000..1d28cf2 --- /dev/null +++ b/api/config/application.rb @@ -0,0 +1,41 @@ +require_relative 'boot' + +require 'rails/all' +# # Pick the frameworks you want: +# require 'active_model/railtie' +# require 'active_job/railtie' +# require 'active_record/railtie' +# require 'active_storage/engine' +# require 'action_controller/railtie' +# require 'action_mailer/railtie' +# require 'action_view/railtie' +# require 'action_cable/engine' +require 'rails_semantic_logger' +# # require 'sprockets/railtie' + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module ClayCMS + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 5.2 + + # Settings in config/environments/* take precedence over those specified here. + # Application configuration can go into files in config/initializers + # -- all .rb files in that directory are automatically loaded after loading + # the framework and any gems in your application. + + # Only loads a smaller set of middleware suitable for API only apps. + # Middleware like session, flash, cookies can be added back manually. + # Skip views, helpers and assets when generating a new resource. + # config.api_only = true + + config.eager_load_paths += Dir["#{config.root}/app/graphql/resolvers/**/"] + config.eager_load_paths += Dir["#{config.root}/app/graphql/mutators/**/"] + config.eager_load_paths += Dir["#{config.root}/app/graphql/functions/**/"] + + config.middleware.delete ApolloUploadServer::Middleware + end +end diff --git a/api/config/boot.rb b/api/config/boot.rb new file mode 100755 index 0000000..b9e460c --- /dev/null +++ b/api/config/boot.rb @@ -0,0 +1,4 @@ +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) + +require 'bundler/setup' # Set up gems listed in the Gemfile. +require 'bootsnap/setup' # Speed up boot time by caching expensive operations. diff --git a/api/config/cable.yml b/api/config/cable.yml new file mode 100755 index 0000000..6f41f6b --- /dev/null +++ b/api/config/cable.yml @@ -0,0 +1,10 @@ +development: + adapter: async + +test: + adapter: async + +production: + adapter: redis + url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> + channel_prefix: claycms_api_production diff --git a/api/config/credentials.yml.enc b/api/config/credentials.yml.enc new file mode 100644 index 0000000..e81bf12 --- /dev/null +++ b/api/config/credentials.yml.enc @@ -0,0 +1 @@ +Dqm6IU8g63ID29DjktlDK4xPgoLRHEbEIMnDiqo3EJG8R6a3W93u0eglCXXLf+DDZ93rD1Wzwi99nwdKf43QPzBBb4PY0qbKZLiqOBkFPtOQFhT2uwXI6OJKP6IfnB8hb+rx7T0GkGalTh5wwD3KpyAthpgdSo/P2EuWnlUg7f2182ulVus9IuG/RjPesH5XdpMTJ3aezutpDv+gAjG4IXPx7W6sScjI3fcrfCDBBfUBHzQkF9janT/UjLFXzJIK3wbJ2G1ccFUMV/rqfoh8SU1zH7zcREuYbO0Kya7QWpsVzS3GDFQ3Dr2jpBq9857CTiEQgYmX32npdKheC6MZSRbS6Q0Uil04q1eEohmRUnjFjV+lPI1wmVe4oAltdcAO0wac3OFNuVkZqpV8gZ/esp0z9Fym9pA7ysLwVsuaGviAC7Ap2VA3lO8aMb0aCpaL4g4DnZaEfD2+StCTHcEhGq2CAegKeZjsvISlB/pV4KVYy6aELwVs5jT2UbfKUywJ6gMPZ43cS/09ztZ5PRYNRTyJbt/upoNit6K4mISihLlKwUGyt8ohiFKJJXnnaMhDf6+jNcB8/Y8LSgp9f1yN6vBULncvkRMBM9T4LXb2YsfQZtqOKu9gm7MGUaglqrkwpekFsd08tKt2GCgE7OfICYdoWFVXAP9TFmLdP0RMaoWCBvFeFKGjl3F3u+i8ErUDqa19HdvqyRLBmaP3yE7YSapsvC9GW/GaPD6LQbiRQ1vq2/kqPdNMnBaNWR/CnzCSYhdjIkQipbxOeNGHMz5jtnRbOjWxfmM/9YhFS4lvmbToIAiY/Webt6cHJe6WHpPZdNZbX+AkYF1XN8t9bke1vdx2PXQZ5yM40zdUkGVnKEFkrrYyqK6C4/FzlJVbz2LTL83VrJZ4qqS8Vm+ZC0I9R0fKPOYyWBh0GjRAo0oAy8Rjteuu7tHIDcG1dk1WrDM7w09H/Oc7SMwDlPuwhKD1twD68NSU2JWfylhBn21p4tGYpQ57zEYLbuPwzQS/66EK+mLXFUqXfrpppMBGQ8H1OBLipx1nDYBpwOA9vN6mFx+EAj/88uS5rW8Oponkaf5uX8mtYtIsDRZRyFEn14GacuoYjTdjlV2Hmr7SoZyOJ3A4+rmEGknTlTRrqmz3v7FrFUunaO0o7zq/QAwr9M9FKCT5DXzO2NMXv3kqdTMyEFvhJkdbeosBaepS5jDWv6rc7U4PkyFTQVBY9eWSelxRMLamypFQymLxkAzy6JkWL9/iy1OLFqs33ySO7KaxS+bJOTxbw1aJB0BQp+CvCo7YZrHnxLmE/OSv/W4bH4fFfdPhAY8oEuDWxowQaogAnb9EKI36dW7FOu16DswTp+4ZtYSaX0jTDWgoqEdiJpjAhI6S2lZCKPLuoCj8OJGC0qiDy2xMLVH9Duim44dOR62o10pRJE2KEs0vxIjdqsNI4+N2+MyEhs85dfM658D32fTQAmDmBXt3bdcNkYjlN0eoUAiARqVxtzNUsAymKOrwOj1EnEZFVJ1jyxcMuIgsZ1u71kr928S/--948l/4DijcbXWS2/--CK9U9u5Gdi+n67//Y66chA== \ No newline at end of file diff --git a/api/config/database.yml.example b/api/config/database.yml.example new file mode 100755 index 0000000..1f86a04 --- /dev/null +++ b/api/config/database.yml.example @@ -0,0 +1,23 @@ +base: &base + adapter: postgresql + encoding: utf8 + username: + password: + host: localhost + pool: <%= ENV.fetch('RAILS_MAX_THREADS') { 5 } %> + +development: + <<: *base + database: clay_cms_development + +test: + <<: *base + database: clay_cms_test + +staging: + <<: *base + database: clay_cms_staging + +production: + <<: *base + database: clay_cms_production diff --git a/api/config/environment.rb b/api/config/environment.rb new file mode 100755 index 0000000..426333b --- /dev/null +++ b/api/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative 'application' + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/api/config/environments/development.rb b/api/config/environments/development.rb new file mode 100755 index 0000000..46d4e19 --- /dev/null +++ b/api/config/environments/development.rb @@ -0,0 +1,57 @@ +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # In the development environment your application's code is reloaded on + # every request. This slows down response time but is perfect for development + # since you don't have to restart the web server when you make code changes. + config.cache_classes = false + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable/disable caching. By default caching is disabled. + # Run rails dev:cache to toggle caching. + if Rails.root.join('tmp', 'caching-dev.txt').exist? + config.action_controller.perform_caching = true + + config.cache_store = :memory_store + config.public_file_server.headers = { + 'Cache-Control' => "public, max-age=#{2.days.to_i}" + } + else + config.action_controller.perform_caching = false + + config.cache_store = :null_store + end + + # Store uploaded files on the local file system (see config/storage.yml for options) + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + config.action_mailer.perform_caching = false + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Raises error for missing translations + # config.action_view.raise_on_missing_translations = true + + # Use an evented file watcher to asynchronously detect changes in source code, + # routes, locales, etc. This feature depends on the listen gem. + # ActiveSupport::EventedFileUpdateChecker || ActiveSupport::FileUpdateChecker + config.file_watcher = ActiveSupport::FileUpdateChecker + + # Enable stdout logger + config.logger = Logger.new(STDOUT) +end diff --git a/api/config/environments/production.rb b/api/config/environments/production.rb new file mode 100755 index 0000000..047d7b6 --- /dev/null +++ b/api/config/environments/production.rb @@ -0,0 +1,116 @@ +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.cache_classes = true + + # Eager load code on boot. This eager loads most of Rails and + # your application in memory, allowing both threaded web servers + # and those relying on copy on write to perform better. + # Rake tasks automatically ignore this option for performance. + config.eager_load = true + + # Full error reports are disabled and caching is turned on. + config.consider_all_requests_local = false + config.action_controller.perform_caching = true + + # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] + # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). + # config.require_master_key = true + + # Disable serving static files from the `/public` folder by default since + # Apache or NGINX already handles this. + config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? + + # Compress JavaScripts and CSS. + # config.assets.js_compressor = :uglifier + # config.assets.css_compressor = :sass + + # Do not fallback to assets pipeline if a precompiled asset is missed. + config.assets.compile = false + + # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.action_controller.asset_host = 'http://assets.example.com' + + # Specifies the header that your server uses for sending files. + # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache + # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX + + # Store uploaded files on the local file system (see config/storage.yml for options) + config.active_storage.service = :local + + # Mount Action Cable outside main process or domain + # config.action_cable.mount_path = nil + # config.action_cable.url = 'wss://example.com/cable' + # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + # config.force_ssl = true + + # Use the lowest log level to ensure availability of diagnostic information + # when problems arise. + config.log_level = :info + + # Prepend all log lines with the following tags. + config.log_tags = [:request_id] + + # Use a different cache store in production. + # config.cache_store = :mem_cache_store + + # Use a real queuing backend for Active Job (and separate queues per environment) + # config.active_job.queue_adapter = :resque + # config.active_job.queue_name_prefix = "claycms_api_#{Rails.env}" + + config.action_mailer.perform_caching = false + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # config.action_mailer.raise_delivery_errors = false + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Send deprecation notices to registered listeners. + config.active_support.deprecation = :notify + + # Use default logging formatter so that PID and timestamp are not suppressed. + config.log_formatter = ::Logger::Formatter.new + + # Use a different logger for distributed setups. + # require 'syslog/logger' + # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') + + if ENV['RAILS_LOG_TO_STDOUT'].present? + logger = ActiveSupport::Logger.new(STDOUT) + logger.formatter = config.log_formatter + config.logger = ActiveSupport::TaggedLogging.new(logger) + end + + config.log_level = ENV['LOG_LEVEL'].upcase if ENV['LOG_LEVEL'].present? + + if ENV['KAFKA_ENABLE'].present? + max_log_level = :trace + config.rails_semantic_logger.format = GbLogger::Formatter.new + config.rails_semantic_logger.add_file_appender = false + config.semantic_logger.backtrace_level = nil + config.semantic_logger.add_appender( + io: STDOUT, + level: max_log_level, + formatter: config.rails_semantic_logger.format + ) + config.semantic_logger.add_appender( + appender: :kafka, + seed_brokers: [ENV['KAFKA_DNS']], + connect_timeout: ENV['KAFKA_TIMEOUT_MS'].to_i, + topic: 'log', + level: max_log_level, + formatter: config.rails_semantic_logger.format + ) + end + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false +end diff --git a/api/config/environments/staging.rb b/api/config/environments/staging.rb new file mode 100644 index 0000000..047d7b6 --- /dev/null +++ b/api/config/environments/staging.rb @@ -0,0 +1,116 @@ +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.cache_classes = true + + # Eager load code on boot. This eager loads most of Rails and + # your application in memory, allowing both threaded web servers + # and those relying on copy on write to perform better. + # Rake tasks automatically ignore this option for performance. + config.eager_load = true + + # Full error reports are disabled and caching is turned on. + config.consider_all_requests_local = false + config.action_controller.perform_caching = true + + # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] + # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). + # config.require_master_key = true + + # Disable serving static files from the `/public` folder by default since + # Apache or NGINX already handles this. + config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? + + # Compress JavaScripts and CSS. + # config.assets.js_compressor = :uglifier + # config.assets.css_compressor = :sass + + # Do not fallback to assets pipeline if a precompiled asset is missed. + config.assets.compile = false + + # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.action_controller.asset_host = 'http://assets.example.com' + + # Specifies the header that your server uses for sending files. + # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache + # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX + + # Store uploaded files on the local file system (see config/storage.yml for options) + config.active_storage.service = :local + + # Mount Action Cable outside main process or domain + # config.action_cable.mount_path = nil + # config.action_cable.url = 'wss://example.com/cable' + # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + # config.force_ssl = true + + # Use the lowest log level to ensure availability of diagnostic information + # when problems arise. + config.log_level = :info + + # Prepend all log lines with the following tags. + config.log_tags = [:request_id] + + # Use a different cache store in production. + # config.cache_store = :mem_cache_store + + # Use a real queuing backend for Active Job (and separate queues per environment) + # config.active_job.queue_adapter = :resque + # config.active_job.queue_name_prefix = "claycms_api_#{Rails.env}" + + config.action_mailer.perform_caching = false + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # config.action_mailer.raise_delivery_errors = false + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Send deprecation notices to registered listeners. + config.active_support.deprecation = :notify + + # Use default logging formatter so that PID and timestamp are not suppressed. + config.log_formatter = ::Logger::Formatter.new + + # Use a different logger for distributed setups. + # require 'syslog/logger' + # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') + + if ENV['RAILS_LOG_TO_STDOUT'].present? + logger = ActiveSupport::Logger.new(STDOUT) + logger.formatter = config.log_formatter + config.logger = ActiveSupport::TaggedLogging.new(logger) + end + + config.log_level = ENV['LOG_LEVEL'].upcase if ENV['LOG_LEVEL'].present? + + if ENV['KAFKA_ENABLE'].present? + max_log_level = :trace + config.rails_semantic_logger.format = GbLogger::Formatter.new + config.rails_semantic_logger.add_file_appender = false + config.semantic_logger.backtrace_level = nil + config.semantic_logger.add_appender( + io: STDOUT, + level: max_log_level, + formatter: config.rails_semantic_logger.format + ) + config.semantic_logger.add_appender( + appender: :kafka, + seed_brokers: [ENV['KAFKA_DNS']], + connect_timeout: ENV['KAFKA_TIMEOUT_MS'].to_i, + topic: 'log', + level: max_log_level, + formatter: config.rails_semantic_logger.format + ) + end + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false +end diff --git a/api/config/environments/test.rb b/api/config/environments/test.rb new file mode 100755 index 0000000..0a38fd3 --- /dev/null +++ b/api/config/environments/test.rb @@ -0,0 +1,46 @@ +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # The test environment is used exclusively to run your application's + # test suite. You never need to work with it otherwise. Remember that + # your test database is "scratch space" for the test suite and is wiped + # and recreated between test runs. Don't rely on the data there! + config.cache_classes = true + + # Do not eager load code on boot. This avoids loading your whole application + # just for the purpose of running a single test. If you are using a tool that + # preloads Rails for running tests, you may have to set it to true. + config.eager_load = false + + # Configure public file server for tests with Cache-Control for performance. + config.public_file_server.enabled = true + config.public_file_server.headers = { + 'Cache-Control' => "public, max-age=#{1.hour.to_i}" + } + + # Show full error reports and disable caching. + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + + # Raise exceptions instead of rendering exception templates. + config.action_dispatch.show_exceptions = false + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory + config.active_storage.service = :test + + config.action_mailer.perform_caching = false + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raises error for missing translations + # config.action_view.raise_on_missing_translations = true +end diff --git a/api/config/initializers/00_require.rb b/api/config/initializers/00_require.rb new file mode 100755 index 0000000..1d61cb9 --- /dev/null +++ b/api/config/initializers/00_require.rb @@ -0,0 +1,11 @@ +require Rails.root.join('lib', 'app_url') +require Rails.root.join('lib', 'credentials') +require Rails.root.join('lib', 'exceptions') +require Rails.root.join('lib', 'mail_address') +require Rails.root.join('lib', 'token') +require Rails.root.join('lib', 'url') +require Rails.root.join('lib', 'uid_generator') +require Rails.root.join('lib', 'rabbit_mq') +require Rails.root.join('lib', 'diff') +require Rails.root.join('lib', 'record_mapper') +require Rails.root.join('lib', 'json_web_token') diff --git a/api/config/initializers/application_controller_renderer.rb b/api/config/initializers/application_controller_renderer.rb new file mode 100755 index 0000000..89d2efa --- /dev/null +++ b/api/config/initializers/application_controller_renderer.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# ActiveSupport::Reloader.to_prepare do +# ApplicationController.renderer.defaults.merge!( +# http_host: 'example.org', +# https: false +# ) +# end diff --git a/api/config/initializers/backtrace_silencers.rb b/api/config/initializers/backtrace_silencers.rb new file mode 100755 index 0000000..59385cd --- /dev/null +++ b/api/config/initializers/backtrace_silencers.rb @@ -0,0 +1,7 @@ +# Be sure to restart your server when you modify this file. + +# You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. +# Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } + +# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. +# Rails.backtrace_cleaner.remove_silencers! diff --git a/api/config/initializers/bullet.rb b/api/config/initializers/bullet.rb new file mode 100644 index 0000000..f2db991 --- /dev/null +++ b/api/config/initializers/bullet.rb @@ -0,0 +1,4 @@ +if Rails.env.development? + Bullet.enable = true + Bullet.bullet_logger = true +end diff --git a/api/config/initializers/content_security_policy.rb b/api/config/initializers/content_security_policy.rb new file mode 100755 index 0000000..d3bcaa5 --- /dev/null +++ b/api/config/initializers/content_security_policy.rb @@ -0,0 +1,25 @@ +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy +# For further information see the following documentation +# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy + +# Rails.application.config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https + +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end + +# If you are using UJS then enable automatic nonce generation +# Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } + +# Report CSP violations to a specified URI +# For further information see the following documentation: +# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only +# Rails.application.config.content_security_policy_report_only = true diff --git a/api/config/initializers/cookies_serializer.rb b/api/config/initializers/cookies_serializer.rb new file mode 100755 index 0000000..5a6a32d --- /dev/null +++ b/api/config/initializers/cookies_serializer.rb @@ -0,0 +1,5 @@ +# Be sure to restart your server when you modify this file. + +# Specify a serializer for the signed and encrypted cookie jars. +# Valid options are :json, :marshal, and :hybrid. +Rails.application.config.action_dispatch.cookies_serializer = :json diff --git a/api/config/initializers/filter_parameter_logging.rb b/api/config/initializers/filter_parameter_logging.rb new file mode 100755 index 0000000..4a994e1 --- /dev/null +++ b/api/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,4 @@ +# Be sure to restart your server when you modify this file. + +# Configure sensitive parameters which will be filtered from the log file. +Rails.application.config.filter_parameters += [:password] diff --git a/api/config/initializers/graphql_rails_logger.rb b/api/config/initializers/graphql_rails_logger.rb new file mode 100755 index 0000000..4a9681b --- /dev/null +++ b/api/config/initializers/graphql_rails_logger.rb @@ -0,0 +1,5 @@ +if Rails.env.development? + GraphQL::RailsLogger.configure do |config| + config.skip_introspection_query = true + end +end diff --git a/api/config/initializers/inflections.rb b/api/config/initializers/inflections.rb new file mode 100755 index 0000000..ac033bf --- /dev/null +++ b/api/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, '\1en' +# inflect.singular /^(ox)en/i, '\1' +# inflect.irregular 'person', 'people' +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym 'RESTful' +# end diff --git a/api/config/initializers/mime_types.rb b/api/config/initializers/mime_types.rb new file mode 100755 index 0000000..dc18996 --- /dev/null +++ b/api/config/initializers/mime_types.rb @@ -0,0 +1,4 @@ +# Be sure to restart your server when you modify this file. + +# Add new mime types for use in respond_to blocks: +# Mime::Type.register "text/richtext", :rtf diff --git a/api/config/initializers/raven.rb b/api/config/initializers/raven.rb new file mode 100644 index 0000000..808bc50 --- /dev/null +++ b/api/config/initializers/raven.rb @@ -0,0 +1,8 @@ +Raven.configure do |config| + config.silence_ready = true + config.dsn = Credentials.get(:sentry_dsn) + config.environments = %w[staging production] + config.excluded_exceptions = [ + Interactor::Failure + ] +end diff --git a/api/config/initializers/shrine.rb b/api/config/initializers/shrine.rb new file mode 100644 index 0000000..37cbff5 --- /dev/null +++ b/api/config/initializers/shrine.rb @@ -0,0 +1,49 @@ +url_options = { + host: ENV['ASSET_HOST'] +} + +if Rails.env.production? || Rails.env.staging? + require 'shrine/storage/s3' + + s3_options = { + access_key_id: Credentials.get(:aws_access_key_id), + secret_access_key: Credentials.get(:aws_secret_access_key), + region: Credentials.get(:s3_region), + bucket: Credentials.get(:s3_bucket) + } + + Shrine.storages = { + cache: Shrine::Storage::S3.new(prefix: 'cache', **s3_options), + store: Shrine::Storage::S3.new(**s3_options) + } +elsif Rails.env.test? + require 'shrine/storage/memory' + + Shrine.storages = { + cache: Shrine::Storage::Memory.new, + store: Shrine::Storage::Memory.new + } +else + require 'shrine/storage/file_system' + + Shrine.storages = { + cache: Shrine::Storage::FileSystem.new('public', prefix: 'uploads/cache'), + store: Shrine::Storage::FileSystem.new('public', prefix: 'uploads') + } + + Shrine.logger = Rails.logger + Shrine.plugin :instrumentation +end + +Shrine.plugin :activerecord +Shrine.plugin :determine_mime_type +Shrine.plugin :infer_extension, force: true +Shrine.plugin :derivatives, versions_compatibility: true +Shrine.plugin :url_options, cache: url_options, store: url_options + +class Shrine::Attacher + def promote(*) + create_derivatives + super + end +end diff --git a/api/config/initializers/sneakers.rb b/api/config/initializers/sneakers.rb new file mode 100644 index 0000000..3f08b3b --- /dev/null +++ b/api/config/initializers/sneakers.rb @@ -0,0 +1,11 @@ +require 'sneakers/metrics/logging_metrics' + +Sneakers.configure({ + amqp: RabbitMq.connection_url, + metrics: Sneakers::Metrics::LoggingMetrics.new, + env: ENV['RAILS_ENV'], + threads: 1, + worker: 1 +}) + +Sneakers.logger.level = Logger::INFO # the default DEBUG is too noisy diff --git a/api/config/initializers/wrap_parameters.rb b/api/config/initializers/wrap_parameters.rb new file mode 100755 index 0000000..bbfc396 --- /dev/null +++ b/api/config/initializers/wrap_parameters.rb @@ -0,0 +1,14 @@ +# Be sure to restart your server when you modify this file. + +# This file contains settings for ActionController::ParamsWrapper which +# is enabled by default. + +# Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. +ActiveSupport.on_load(:action_controller) do + wrap_parameters format: [:json] +end + +# To enable root element in JSON for ActiveRecord objects. +# ActiveSupport.on_load(:active_record) do +# self.include_root_in_json = true +# end diff --git a/api/config/locales/en.yml b/api/config/locales/en.yml new file mode 100755 index 0000000..decc5a8 --- /dev/null +++ b/api/config/locales/en.yml @@ -0,0 +1,33 @@ +# Files in the config/locales directory are used for internationalization +# and are automatically loaded by Rails. If you want to use locales other +# than English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t 'hello' +# +# In views, this is aliased to just `t`: +# +# <%= t('hello') %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# The following keys must be escaped otherwise they will not be retrieved by +# the default I18n backend: +# +# true, false, on, off, yes, no +# +# Instead, surround them with single quotes. +# +# en: +# 'true': 'foo' +# +# To learn more, please read the Rails Internationalization guide +# available at http://guides.rubyonrails.org/i18n.html. + +en: + hello: "Hello world" diff --git a/api/config/puma.rb b/api/config/puma.rb new file mode 100755 index 0000000..1e84d96 --- /dev/null +++ b/api/config/puma.rb @@ -0,0 +1,34 @@ +# Puma can serve each request in a thread from an internal thread pool. +# The `threads` method setting takes two numbers: a minimum and maximum. +# Any libraries that use thread pools should be configured to match +# the maximum value specified for Puma. Default is set to 5 threads for minimum +# and maximum; this matches the default thread size of Active Record. +# +threads_count = ENV.fetch('RAILS_MAX_THREADS') { 5 } +threads threads_count, threads_count + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +# +port ENV.fetch('PORT') { 3000 } + +# Specifies the `environment` that Puma will run in. +# +environment ENV.fetch('RAILS_ENV') { 'development' } + +# Specifies the number of `workers` to boot in clustered mode. +# Workers are forked webserver processes. If using threads and workers together +# the concurrency of the application would be max `threads` * `workers`. +# Workers do not work on JRuby or Windows (both of which do not support +# processes). +# +# workers ENV.fetch('WEB_CONCURRENCY') { 2 } + +# Use the `preload_app!` method when specifying a `workers` number. +# This directive tells Puma to first boot the application and load code +# before forking the application. This takes advantage of Copy On Write +# process behavior so workers use less memory. +# +# preload_app! + +# Allow puma to be restarted by `rails restart` command. +plugin :tmp_restart diff --git a/api/config/routes.rb b/api/config/routes.rb new file mode 100755 index 0000000..aef1fca --- /dev/null +++ b/api/config/routes.rb @@ -0,0 +1,12 @@ +Rails.application.routes.draw do + namespace :v1 do + resources :entities, only: [:index, :show] + get '/content', to: 'content#find' + end + + mount GraphiQL::Rails::Engine, at: '/graphiql', graphql_path: '/graphql' if Rails.env.development? + + post '/graphql', to: 'graphql#execute' + + post '/rest/version/1/logLevel/:level', to: 'control_interface#configure_logging' +end diff --git a/api/config/spring.rb b/api/config/spring.rb new file mode 100755 index 0000000..9fa7863 --- /dev/null +++ b/api/config/spring.rb @@ -0,0 +1,6 @@ +%w[ + .ruby-version + .rbenv-vars + tmp/restart.txt + tmp/caching-dev.txt +].each { |path| Spring.watch(path) } diff --git a/api/config/storage.yml b/api/config/storage.yml new file mode 100755 index 0000000..d32f76e --- /dev/null +++ b/api/config/storage.yml @@ -0,0 +1,34 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +# Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket + +# Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) +# microsoft: +# service: AzureStorage +# storage_account_name: your_account_name +# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> +# container: your_container_name + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/api/db/schema.rb b/api/db/schema.rb new file mode 100755 index 0000000..ca5b42e --- /dev/null +++ b/api/db/schema.rb @@ -0,0 +1,244 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# Note that this schema.rb definition is the authoritative source for your +# database schema. If you need to create the application database on another +# system, you should be using db:schema:load, not running all the migrations +# from scratch. The latter is a flawed and unsustainable approach (the more migrations +# you'll amass, the slower it'll run and the greater likelihood for issues). +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema.define(version: 2020_09_09_072630) do + + # These are extensions that must be enabled in order to support this database + enable_extension "plpgsql" + enable_extension "uuid-ossp" + + create_table "assets", force: :cascade do |t| + t.bigint "project_id" + t.string "name" + t.text "file_data" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["project_id"], name: "index_assets_on_project_id" + end + + create_table "auth_nonces", force: :cascade do |t| + t.string "nonce" + t.datetime "expires_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["nonce"], name: "index_auth_nonces_on_nonce", unique: true + end + + create_table "auth_tokens", force: :cascade do |t| + t.bigint "user_id" + t.string "jti", null: false + t.string "aud", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["aud"], name: "index_auth_tokens_on_aud" + t.index ["jti"], name: "index_auth_tokens_on_jti", unique: true + t.index ["user_id"], name: "index_auth_tokens_on_user_id" + end + + create_table "entities", force: :cascade do |t| + t.bigint "project_id" + t.string "name" + t.string "label" + t.boolean "singleton", default: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "parent_id" + t.string "uid" + t.index ["name"], name: "index_entities_on_name" + t.index ["parent_id"], name: "index_entities_on_parent_id" + t.index ["project_id"], name: "index_entities_on_project_id" + end + + create_table "exports", force: :cascade do |t| + t.bigint "project_id" + t.text "file_data" + t.integer "status", default: 0, null: false + t.string "name" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["project_id"], name: "index_exports_on_project_id" + end + + create_table "field_hierarchies", id: false, force: :cascade do |t| + t.bigint "ancestor_id", null: false + t.bigint "descendant_id", null: false + t.integer "generations", null: false + t.index ["ancestor_id", "descendant_id", "generations"], name: "field_anc_desc_idx", unique: true + t.index ["descendant_id"], name: "field_desc_idx" + end + + create_table "fields", force: :cascade do |t| + t.bigint "entity_id" + t.string "label" + t.string "name" + t.integer "data_type" + t.text "default_value" + t.text "validations", default: "{}" + t.string "hint" + t.integer "position", default: 0 + t.string "editor" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "parent_id" + t.integer "element_type" + t.bigint "referenced_entity_id" + t.text "settings", default: "{}" + t.string "uid" + t.index ["entity_id"], name: "index_fields_on_entity_id" + t.index ["name"], name: "index_fields_on_name" + t.index ["referenced_entity_id"], name: "index_fields_on_referenced_entity_id" + end + + create_table "key_pairs", force: :cascade do |t| + t.bigint "project_id" + t.string "public_key" + t.datetime "expires_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["project_id"], name: "index_key_pairs_on_project_id" + t.index ["public_key"], name: "index_key_pairs_on_public_key" + end + + create_table "locales", force: :cascade do |t| + t.bigint "project_id" + t.string "language" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["project_id"], name: "index_locales_on_project_id" + end + + create_table "projects", force: :cascade do |t| + t.string "name" + t.bigint "team_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "uid" + t.index ["team_id"], name: "index_projects_on_team_id" + end + + create_table "properties", force: :cascade do |t| + t.bigint "record_id" + t.bigint "field_id" + t.text "value" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "asset_id" + t.bigint "parent_id" + t.bigint "linked_record_id" + t.integer "position", default: 0 + t.string "uid" + t.index ["asset_id"], name: "index_properties_on_asset_id" + t.index ["field_id"], name: "index_properties_on_field_id" + t.index ["linked_record_id"], name: "index_properties_on_linked_record_id" + t.index ["record_id"], name: "index_properties_on_record_id" + end + + create_table "property_hierarchies", id: false, force: :cascade do |t| + t.bigint "ancestor_id", null: false + t.bigint "descendant_id", null: false + t.integer "generations", null: false + t.index ["ancestor_id", "descendant_id", "generations"], name: "property_anc_desc_idx", unique: true + t.index ["descendant_id"], name: "property_desc_idx" + end + + create_table "records", force: :cascade do |t| + t.bigint "entity_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "uid" + t.index ["entity_id"], name: "index_records_on_entity_id" + end + + create_table "relationships", force: :cascade do |t| + t.bigint "entity_id" + t.bigint "field_id" + t.integer "linked_entity_id" + t.integer "linked_field_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["entity_id"], name: "index_relationships_on_entity_id" + t.index ["field_id"], name: "index_relationships_on_field_id" + t.index ["linked_entity_id"], name: "index_relationships_on_linked_entity_id" + t.index ["linked_field_id"], name: "index_relationships_on_linked_field_id" + end + + create_table "resources", force: :cascade do |t| + t.bigint "project_id" + t.string "name" + t.text "file_data" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["project_id"], name: "index_resources_on_project_id" + end + + create_table "restores", force: :cascade do |t| + t.bigint "project_id" + t.string "url" + t.integer "status", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["project_id"], name: "index_restores_on_project_id" + end + + create_table "team_memberships", force: :cascade do |t| + t.bigint "team_id" + t.bigint "user_id" + t.integer "role", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["team_id"], name: "index_team_memberships_on_team_id" + t.index ["user_id"], name: "index_team_memberships_on_user_id" + end + + create_table "teams", force: :cascade do |t| + t.string "name" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "transfer_owner_id" + t.string "transfer_digest" + t.datetime "transfer_generated_at" + t.index ["transfer_owner_id"], name: "index_teams_on_transfer_owner_id" + end + + create_table "users", force: :cascade do |t| + t.string "first_name", limit: 50 + t.string "last_name", limit: 50 + t.string "email" + t.text "profile_picture_data" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "external_uid" + t.index ["email"], name: "index_users_on_email" + end + + add_foreign_key "assets", "projects" + add_foreign_key "auth_tokens", "users" + add_foreign_key "entities", "projects" + add_foreign_key "exports", "projects" + add_foreign_key "fields", "entities" + add_foreign_key "fields", "entities", column: "referenced_entity_id" + add_foreign_key "key_pairs", "projects" + add_foreign_key "locales", "projects" + add_foreign_key "projects", "teams" + add_foreign_key "properties", "assets" + add_foreign_key "properties", "fields" + add_foreign_key "properties", "records" + add_foreign_key "properties", "records", column: "linked_record_id" + add_foreign_key "records", "entities" + add_foreign_key "relationships", "entities" + add_foreign_key "relationships", "fields" + add_foreign_key "resources", "projects" + add_foreign_key "restores", "projects" + add_foreign_key "team_memberships", "teams" + add_foreign_key "team_memberships", "users" + add_foreign_key "teams", "users", column: "transfer_owner_id" +end diff --git a/api/db/seeds.rb b/api/db/seeds.rb new file mode 100755 index 0000000..da30f19 --- /dev/null +++ b/api/db/seeds.rb @@ -0,0 +1,52 @@ +user = User.create!( + email: 'test@keepworks.com', + first_name: 'Test', + last_name: 'User', +) + +team = Team.create!( + name: 'Clay' +) + +team.team_memberships.create!( + user: user, + role: :owner +) + +project = team.projects.create!( + name: 'Test Project' +) + +project.key_pairs.create! + +project.assets.create!( + name: 'Logo', + file: File.open(Rails.root.join('fixtures', 'assets', 'logo.png')) +) + +movie_entity = project.entities.create!(label: 'Movie', name: 'movie') +actor_entity = project.entities.create!(label: 'Actor', name: 'actor') + +movie_name = movie_entity.fields.create!(label: 'Name', name: 'name', data_type: :single_line_text) +movie_genre = movie_entity.fields.create!(label: 'Genre', name: 'genre', data_type: :single_line_text) +movie_year = movie_entity.fields.create!(label: 'Year of Release', name: 'year', data_type: :number) + +actor_first_name = actor_entity.fields.create!(label: 'First Name', name: 'first_name', data_type: :single_line_text) +actor_last_name = actor_entity.fields.create!(label: 'Last Name', name: 'last_name', data_type: :single_line_text) + +movie_entity.records.create!(properties_attributes: [ + { field: movie_name, value: 'Back to the Future' }, + { field: movie_genre, value: 'Adventure' }, + { field: movie_year, value: 1985 } +]) + +movie_entity.records.create!(properties_attributes: [ + { field: movie_name, value: 'Back to the Future II' }, + { field: movie_genre, value: 'Adventure' }, + { field: movie_year, value: 1989 } +]) + +actor_entity.records.create!(properties_attributes: [ + { field: actor_first_name, value: 'Michael' }, + { field: actor_last_name, value: 'J. Fox' } +]) diff --git a/api/fixtures/assets/logo.png b/api/fixtures/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7951d1a273fb1fd120c41182114a35823f2034fd GIT binary patch literal 2401 zcmV-n37+Lp=d2pdk!L5$Z!c zFI}M?jkQomJ#jX~^U@va0ayfeFclX-GQsnq?%+6nD#csa0+I=y4>ceCp-GyLGa;Ge z`A|QACTR$^g=CWFL%qmhnBN&&K~gLS+hK3ifm9K7aJZo+e5i)n4ztp(D1f9uO3g?b(aL6fi!O;Oiz4G%yKogoqZ05v=;{04!)s7)bH<~M{UVIua1#8?&{atzco z2z#b6)SCDT8p1-H;IM}3SPdl9Rc<&VQf4UADU>52_Vk{q@ zK~rEEPDn$j^)U(>!3Lb>u!hD^!$**i9)cP^g9yJl6&m0vh@(fK0oLLmNQ`CSOQ=N& ze#gBy0f*sOT!{}b7izHr2d6O9d<+QqiuEC`$5}W57vXvIMVYa-J(66*o|X-w;kJ+uQEC`f{;9uZ5?>B-Lp(%jfEmb##MpLN2{klDHeB90n+r8Omx56D!bYf} ztK;1WSw`@!l3c^tP{R<2qvlY<{Sb)p5n(j~;^^u$eLJx&4$t5r>W`p?_Q-)aI>vG; zZ85fp7;0;%VFIc{LTCoXswCHtjmc2MQII4RVwq)*UbB3tq!At4oD5;SNH0i&uH5ngjm@&;*)5 z9Q_1MU^XO4wJ;rOF&KG}G`Iw6XblO?{^0s`=uquz`~ectRgR%ngYAR5E+h$^f}V@S z_!MvRD#TH5s43)N_$6opH$l>%3Dj^ZB!WV`ZFnd`sEwe8DK-VwOdYa9 zd?LEr7V1W50?$`)s9h>3)H#-MlEf_3DVEI>Ns;HALIpZF9%Rb4d|S5))oh_IfhO=y z8ou;*ssvy9*F($c6T?s!1dfHmyFEmxXG0CYLTqq4)X)NA3v~uGf#wiLKW_#PQ)=iL z!%)qQgx(c=)G*63sw&1#5R0#%hJ#@ZbyOP0Q}3o>Jk`Y*!TgZs@zfefFGi znBm>!5Ngn<>NSX=wq>+70Ai@r1OK{^G@EAbq{T4Q@z8P>K~m)PkfBzyOp@j!KZ&jB zTOo#6Y@y~sVyeC6iMo2sLj4_T*bR~bot#1q9#vfpDQB?dFw2$@QydPZJPe7cx1okh zAQ9BT5X0vYLVY9hfe^b|*bpJqJuI`)9uNo)gc=${?EEpmL{4^mf(`pEQL_l4o*MZD zOuHvscBULgRr^EmQG&l`mF&Ukju1!nEBV3c9L%=tqaOhYsUOsEW`t1lun=mv7!uOf zSQb50>!_+J^0CNuTH*34NOR^k?@;d)3SE-&{Lg?zbh6*LLUaU~>1w!~DZp_}7b&&#k8YPdNCGt9bJ z3^h!_K8~*rb%de>FGdX2I;yf9URi>|WJ5g$8ekYCgoi`CE`1>iAu(_|N}z`6xFX;H z^^W)qWl%#GWJL}YxCLsl2G65bz(JY2G0%9gaa)63HLQdt@PX?E6f>X!&dlHi6sz$Q zp2aQLDR!@;0JG5+58+M};T`m_1`bR{{S@yCwFww8cE*Qz1`pvC{9*}d6skuK)f`pX zm0|BVUFlOFvn%Sk?uOal)|J#|m8vP7^HM*CCEKIO$oa`1g)I(4L4X^;)LLxXAU45vVwX;*P z(<#C4iu)hm<3;j2)Nq;qfoO5!*+x@yksB^lyNhG;BPO@UKBxn!RJ`}0hSw_khxQJ_ zwh+%lH7vJ0MC}wl)bQ6Cn&#tlNR{W^2{rr)Nqwlm4vyQOrFa7ckSfa?12tTbzEFV! zu^8%LJkEhsIo>Hy!%XC)H&o#0koVX3z}b*0!}|$pcoCBRP=S-M5*or#6rmo(+idM* z*>Nn!c9{zmI1KZlA#FkjJb@#S1MxN+Z$`ev#3IT^&0uH>Y{VG+gvNLaPoW0Ht90(b zQ-*^pCRCshEud+#6U6h3P(!;AbK)JU=R*am;XM?`F;vfoYT6IKpe&A|dOlRs{`d+d zaSYY-p_+EUQy3lBP(2^2=`cKpu2>hxP(2^2sSqck2rbYaBQO<9vB8Jx0l+^1xV?_h T2iuUB00000NkvXXu0mjf_%2B_ literal 0 HcmV?d00001 diff --git a/api/lib/app_url.rb b/api/lib/app_url.rb new file mode 100644 index 0000000..b038d24 --- /dev/null +++ b/api/lib/app_url.rb @@ -0,0 +1,7 @@ +class AppUrl + def self.build(path = '', query = {}) + options = { host: ENV['APP_HOST'], port: ENV['APP_PORT'], path: path, query: query.presence && query.to_query } + builder_class = options[:port] == '443' ? URI::HTTPS : URI::HTTP + builder_class.build(options).to_s + end +end diff --git a/api/lib/credentials.rb b/api/lib/credentials.rb new file mode 100644 index 0000000..63045a4 --- /dev/null +++ b/api/lib/credentials.rb @@ -0,0 +1,5 @@ +class Credentials < Rails::Railtie + def self.get(*args) + Rails.application.credentials.dig(Rails.env.to_sym, *args) + end +end diff --git a/api/lib/diff.rb b/api/lib/diff.rb new file mode 100644 index 0000000..bae2796 --- /dev/null +++ b/api/lib/diff.rb @@ -0,0 +1,75 @@ +class Diff + # This sorts both remote data and local data by their uids and compares them. + # Let's assume below are the uids of records. + # local remote + # 1 2 + # 4 3 + # 6 4 + # 7 5 + # 6 + # 7 + # 8 + # 9 + # + # 1st pass 1 < 2 - delete 1 (present in local but not in remote) + # 2nd pass 4 > 2 - create 2 + # 3rd pass 4 > 3 - create 3 (present in remote but not in local) + # 4th pass 4 == 4 - update if different + # 5th pass 6 > 5 - create 5 + # 6th pass 6 == 6 - update if different + # 7th pass 7 == 7 - update if different + # 8th pass - local empty + # + # add remaining 8, 9 to create list + # + def self.perform(local, remote, identifier: :uid, ignore: []) # rubocop:disable Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity + sorted_local = local.sort_by { |c| c[identifier] } + sorted_remote = remote.sort_by { |c| c[identifier] } + + diff = { + create: [], + update: [], + destroy: [] + } + + i = 0 # local iterator + j = 0 # remote iterator + + while i < local.length && j < remote.length + local_object = clean(sorted_local[i], ignore) + remote_object = clean(sorted_remote[j], ignore) + + if local_object[identifier] == remote_object[identifier] + is_updated = local_object != remote_object + + diff[:update] << sorted_remote[j] if is_updated + + j += 1 + i += 1 + elsif local_object[identifier] < remote_object[identifier] + diff[:destroy] << sorted_local[i] + i += 1 + else + diff[:create] << sorted_remote[j] + j += 1 + end + end + + diff[:destroy] += sorted_local[i..local.length] if i != local.length + diff[:create] += sorted_remote[j..remote.length] if j != remote.length + diff + end + + def self.clean(hash, ignored_paths) + cloned_hash = hash.deep_dup + + ignored_paths.each do |ignored_path| + *path, final_key = ignored_path + to_delete = path.empty? ? cloned_hash : cloned_hash.dig(*path) + + to_delete.except! final_key if to_delete.present? + end + + cloned_hash + end +end diff --git a/api/lib/exceptions.rb b/api/lib/exceptions.rb new file mode 100755 index 0000000..45e7afd --- /dev/null +++ b/api/lib/exceptions.rb @@ -0,0 +1,52 @@ +module Exceptions + class APIError < StandardError + MESSAGE = nil + TYPE = 'UNPROCESSABLE_ENTITY'.freeze + STATUS = 422 + + def initialize(message = nil) + super(message || self.class::MESSAGE) + end + + def type + self.class::TYPE + end + + def status + self.class::STATUS + end + end + + class FormErrors < APIError + attr_reader :errors + + def initialize(errors) + @errors = errors + super(nil) + end + end + + class Unauthorized < APIError + MESSAGE = 'You must be logged in to continue.'.freeze + TYPE = 'UNAUTHORIZED'.freeze + STATUS = 401 + end + + class Forbidden < APIError + MESSAGE = 'You are not allowed to access this resource.'.freeze + TYPE = 'FORBIDDEN'.freeze + STATUS = 403 + end + + class NotFound < APIError + MESSAGE = 'The resource you are looking for does not exist.'.freeze + TYPE = 'NOT_FOUND'.freeze + STATUS = 404 + end + + class InternalServerError < APIError + MESSAGE = "We're sorry, but something went wrong! Please try again after some time.".freeze + TYPE = 'INTERNAL_SERVER_ERROR'.freeze + STATUS = 500 + end +end diff --git a/api/lib/gb_logger.rb b/api/lib/gb_logger.rb new file mode 100644 index 0000000..e92db67 --- /dev/null +++ b/api/lib/gb_logger.rb @@ -0,0 +1,63 @@ +module GbLogger + class Formatter < SemanticLogger::Formatters::Default + def initialize(time_format: :iso_8601, log_host: true, log_application: true, precision: PRECISION) + super(time_format: time_format, log_host: log_host, log_application: log_application, precision: precision) + end + + def status_code + # Payload Example: '{ :path=>"/graphql" :status=>200, :status_message=>"OK" }' + status_code_match = payload.match(/:status=>([0-9]*),/) if payload + return '' unless status_code_match + + status_code_match.captures[0].to_s + end + + def method + # Message Example: '-- Completed #configure_logging' + # Where configure_logging is the desired method + method_match = message.match(/^--\sCompleted\s#(\w+)$/) if message + return '' unless method_match + + method_match.captures[0].to_s + end + + def service_name + ENV['SERVICE_NAME'] || '' + end + + def pod_name + ENV['POD_NAME'] || '' + end + + def subscriber + '' + end + + def device + '' + end + + def level + log.level.upcase || '' + end + + def thread + "[#{log.thread_name}]" || '' + end + + def file + name + end + + def line + '0' + end + + def call(log, logger) + self.log = log + self.logger = logger + + [time, service_name, pod_name, subscriber, device, level, file, thread, method, line, status_code, message, payload, exception].compact.join(' ') + end + end +end diff --git a/api/lib/json_web_token.rb b/api/lib/json_web_token.rb new file mode 100644 index 0000000..0b6921f --- /dev/null +++ b/api/lib/json_web_token.rb @@ -0,0 +1,9 @@ +class JsonWebToken + def self.encode(payload = {}) + JWT.encode(payload, ENV['HMAC_SECRET'], 'HS256') + end + + def self.decode(token) + JWT.decode(token, ENV['HMAC_SECRET'], true, { algorithm: 'HS256' })[0] + end +end diff --git a/api/lib/mail_address.rb b/api/lib/mail_address.rb new file mode 100755 index 0000000..64bc6f1 --- /dev/null +++ b/api/lib/mail_address.rb @@ -0,0 +1,8 @@ +# Safer subclass of Mail::Address with convenience methods +class MailAddress < Mail::Address + EMAIL_REGEX = /\A[^@\s]+@[^@\s]+\z/.freeze + + def self.valid_email?(value) + (value =~ EMAIL_REGEX) != nil + end +end diff --git a/api/lib/rabbit_mq.rb b/api/lib/rabbit_mq.rb new file mode 100644 index 0000000..30bc390 --- /dev/null +++ b/api/lib/rabbit_mq.rb @@ -0,0 +1,5 @@ +module RabbitMq + def self.connection_url + ENV['CLOUDAMQP_URL'] || "amqp://#{ENV['RABBIT_HOST']}:#{ENV['RABBIT_PORT']}" + end +end diff --git a/api/lib/record_mapper.rb b/api/lib/record_mapper.rb new file mode 100644 index 0000000..f1614f7 --- /dev/null +++ b/api/lib/record_mapper.rb @@ -0,0 +1,92 @@ +class RecordMapper + def initialize + @memoized_entities = {} + @fields_mapping = [] + end + + def to_json(record, key_type = nil) + return if record.blank? + + key_type ||= :camelize + + resolved_values = resolve_values(record) + + json = record.slice(:id, :created_at, :updated_at) + .merge(entity_name: record.entity.name) + .merge(resolved_values) + + json.deep_transform_keys! { |k| k.camelize(:lower) } if key_type.try(:to_sym) == :camelize + json + end + + private + + def resolve_values(record) + root_fields = root_fields_of(record.entity) + properties = record.nested_properties + + root_fields.each_with_object({}) do |field, obj| + property = properties.find { |p| p.field_id == field.id } + + obj[field.name] = property_value(property, properties) + end + end + + def property_value(property, properties) # rubocop:disable Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity + return if property.blank? + + field = field_of(property) + child_properties = child_properties_of(property, properties) + + if field.array? + child_properties.map { |cp| property_value(cp, properties) }.compact + elsif field.key_value? + child_properties.each_with_object({}) do |child_property, obj| + child_field = field_of(child_property) + + obj[child_field.name] = property_value(child_property, properties) + end + elsif field.reference? + to_json(property.linked_record) + elsif field.image? || field.file? + property.asset&.resolve_url_for(field) + elsif field.boolean? + property.value == 't' || property.value.downcase == 'true' || property.value == '1' + else + property.value + end + end + + def root_fields_of(entity) + memoize(entity) if !memoized? entity + + memoized(entity)[:root_fields] + end + + def memoize(entity) + fields = entity.nested_fields + @memoized_entities[entity.id] = { root_fields: fields.select { |f| f.parent_id.blank? } } + + map_field_id_to_field_object(fields) + end + + def memoized?(entity) + memoized(entity).present? + end + + def memoized(entity) + @memoized_entities[entity.id] + end + + def map_field_id_to_field_object(fields) + (fields || []).each { |f| @fields_mapping[f.id] = f } + end + + def field_of(property) + @fields_mapping[property.field_id] + end + + def child_properties_of(property, properties) + properties.select { |p| p.parent_id == property.id }.sort_by(&:position) + end +end diff --git a/api/lib/tasks/project.rake b/api/lib/tasks/project.rake new file mode 100644 index 0000000..67c7188 --- /dev/null +++ b/api/lib/tasks/project.rake @@ -0,0 +1,155 @@ +require 'csv' +require 'json' + +FOLDER_PATH = 'public/exports'.freeze +FILE_NAME = 'data.json'.freeze +ASSET_PATH = "#{FOLDER_PATH}/assets".freeze +FILE_PATH = "#{FOLDER_PATH}/#{FILE_NAME}".freeze + +namespace :project do + desc 'Export Project data to a JSON file' + task export: :environment do + current_project = Project.find_by(id: ENV['PROJECT_ID']) + + raise 'Project not found!' if current_project.blank? + + entities = current_project.entities.includes(:fields).order(created_at: :asc) + data = { project: current_project.slice(:name), entities: [], records: [] } + + def process_properties_for(record) + record_json = {} + record.properties.each do |property| + record_json[property.field.name] = property_value(property) + end + + record_json + end + + def property_value(property) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + if property.field.array? + property.children.sort_by(&:position).map do |child| + child_value = { position: child.position } + value = property_value(child) + if child.field.reference? + child_value[:id] = value + else + child_value[:value] = value + end + + child_value + end + .compact + elsif property.field.key_value? + property.children.each_with_object({}) { |child, obj| obj[child.field.name] = property_value(child); } + elsif property.field.reference? + property.linked_record_id + elsif property.field.image? || property.field.file? + { + name: property.asset&.name, + url: property.asset&.resolve_url_for(property.field) + } + else + property.value + end + end + + def process_fields(fields_all, root_fields) + root_fields.map do |field| + parsed_field = field.slice(:label, :name, :data_type, :default_value, :validations, :hint, :position, :editor, :element_type, :settings) + child_fields = fields_all.select { |f| f.parent == field } + is_sub_parent = child_fields.length == 1 && field.array? + + parsed_field[:referenced_entity_name] = field.referenced_entity&.name || '' + + if is_sub_parent + sub_parent = child_fields.first + sub_child_fields = fields_all.select { |f| f.parent == sub_parent } + + parsed_field[:children] = process_fields(fields_all, sub_child_fields) + elsif child_fields.present? + parsed_field[:children] = process_fields(fields_all, child_fields) + end + + parsed_field + end + end + + entities.each do |entity| + parsed_entity = entity.slice(:name, :label, :singleton) + parsed_entity[:parent_name] = entity.parent&.name || '' + + parsed_entity[:fields] = entity.fields.map do |field| + flattened_fields = field.self_and_descendants + root_fields = flattened_fields.reject(&:parent) + + process_fields(flattened_fields, root_fields) + end + .flatten + .compact + + data[:entities] << parsed_entity + end + + data[:records] = current_project.entities + .map(&:records) + .flatten + .sort_by(&:created_at) + .map do |record| + parsed_record = { ref_id: record.id, entity_name: record.entity.name } + + parsed_record[:traits] = process_properties_for(record) + + parsed_record + end + + Dir.mkdir(FOLDER_PATH) unless Dir.exist?(FOLDER_PATH) + File.open(FILE_PATH, 'w') do |f| + f.write(data.to_json) + end + end + + desc 'Import Project data from a JSON File' + task import: :environment do + team = Team.find_by(id: ENV['TEAM_ID']) + + raise "Team id: #{ENV['TEAM_ID']}, not found!" if team.blank? + + data = JSON.parse(File.read(FILE_PATH), symbolize_names: true) + + # Step 1 - Create project + project = CreateProject.call!(params: data[:project], team: team).project + + # Step 2 - Create all entities + data[:entities].each do |entity| + parent_entity = Entity.find_by(name: entity[:parent_name]) + + e = project.entities.new(entity.except(:parent_name, :fields)) + e.parent_id = parent_entity&.id + + e.save! + end + + # Step 3 - Add fields to entities (Need all entity for referenced_entities) + data[:entities].each do |entity| + loaded_entity = Entity.find_by(name: entity[:name]) + + entity[:fields].map do |field| + CreateField.call!(params: field, entity: loaded_entity, is_import: true) + end + end + + # Step 4 - Create records without the reference records. + record_index = {} + data[:records].each do |record| + entity = Entity.find_by(name: record[:entity_name]) + result = CreateRecord.call!(params: record, entity: entity, is_import: true) + record_index[record[:ref_id]] = result.record.id + end + + # Step 5 - Update records to map reference records + data[:records].each do |record| + resolved_record = Record.find(record_index[record[:ref_id]]) + UpdateRecord.call!(params: record, record: resolved_record, is_import: true, record_index: record_index) + end + end +end diff --git a/api/lib/token.rb b/api/lib/token.rb new file mode 100755 index 0000000..aac9863 --- /dev/null +++ b/api/lib/token.rb @@ -0,0 +1,15 @@ +module Token + def self.encode(id, digest) + Base64.urlsafe_encode64("#{id}:#{digest}") + end + + def self.decode(token) + begin + decoded_token = Base64.urlsafe_decode64(token) + rescue ArgumentError + return + end + + (decoded_token || '').split(':') + end +end diff --git a/api/lib/uid_generator.rb b/api/lib/uid_generator.rb new file mode 100644 index 0000000..3516f44 --- /dev/null +++ b/api/lib/uid_generator.rb @@ -0,0 +1,21 @@ +module UidGenerator + def self.populate(table, field = 'uid') + if adapter == 'postgresql' + load_extension = 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp";' + sql = "UPDATE #{table} SET #{field}=uuid_generate_v4();" + + ActiveRecord::Base.connection.execute(load_extension) + ActiveRecord::Base.connection.execute(sql) + elsif adapter == 'mysql2' + sql = "UPDATE #{table} SET #{field}=(SELECT uuid());" + + ActiveRecord::Base.connection.execute(sql) + else + raise 'Unknown database adapter' + end + end + + def self.adapter + ActiveRecord::Base.connection.instance_values['config'][:adapter] + end +end diff --git a/api/lib/url.rb b/api/lib/url.rb new file mode 100644 index 0000000..af3bc73 --- /dev/null +++ b/api/lib/url.rb @@ -0,0 +1,10 @@ +module URL + def self.valid?(value) + url = begin + URI.parse(value) + rescue StandardError + false + end + url.is_a?(URI::HTTP) || url.is_a?(URI::HTTPS) + end +end diff --git a/api/log/.keep b/api/log/.keep new file mode 100755 index 0000000..e69de29 diff --git a/api/package.json b/api/package.json new file mode 100755 index 0000000..4501bc1 --- /dev/null +++ b/api/package.json @@ -0,0 +1,5 @@ +{ + "name": "claycms-api", + "private": true, + "dependencies": {} +} diff --git a/api/public/404.html b/api/public/404.html new file mode 100755 index 0000000..2be3af2 --- /dev/null +++ b/api/public/404.html @@ -0,0 +1,67 @@ + + + + The page you were looking for doesn't exist (404) + + + + + + +
+
+

The page you were looking for doesn't exist.

+

You may have mistyped the address or the page may have moved.

+
+

If you are the application owner check the logs for more information.

+
+ + diff --git a/api/public/422.html b/api/public/422.html new file mode 100755 index 0000000..c08eac0 --- /dev/null +++ b/api/public/422.html @@ -0,0 +1,67 @@ + + + + The change you wanted was rejected (422) + + + + + + +
+
+

The change you wanted was rejected.

+

Maybe you tried to change something you didn't have access to.

+
+

If you are the application owner check the logs for more information.

+
+ + diff --git a/api/public/500.html b/api/public/500.html new file mode 100755 index 0000000..78a030a --- /dev/null +++ b/api/public/500.html @@ -0,0 +1,66 @@ + + + + We're sorry, but something went wrong (500) + + + + + + +
+
+

We're sorry, but something went wrong.

+
+

If you are the application owner check the logs for more information.

+
+ + diff --git a/api/public/apple-touch-icon-precomposed.png b/api/public/apple-touch-icon-precomposed.png new file mode 100755 index 0000000..e69de29 diff --git a/api/public/apple-touch-icon.png b/api/public/apple-touch-icon.png new file mode 100755 index 0000000..e69de29 diff --git a/api/public/favicon.ico b/api/public/favicon.ico new file mode 100755 index 0000000..e69de29 diff --git a/api/public/robots.txt b/api/public/robots.txt new file mode 100755 index 0000000..78a0cca --- /dev/null +++ b/api/public/robots.txt @@ -0,0 +1,4 @@ +# See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file + +User-agent: * +Disallow: / diff --git a/api/spec/factories/assets.rb b/api/spec/factories/assets.rb new file mode 100644 index 0000000..4905cd7 --- /dev/null +++ b/api/spec/factories/assets.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :asset do + project + + sequence(:name) { |n| "Asset #{n}" } + file { File.open(Rails.root.join('fixtures', 'assets', 'logo.png')) } + end +end diff --git a/api/spec/factories/auth_nonces.rb b/api/spec/factories/auth_nonces.rb new file mode 100644 index 0000000..2936f7c --- /dev/null +++ b/api/spec/factories/auth_nonces.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :auth_nonce do + nonce "MyString" + expires_at "2020-09-03 14:48:52" + end +end diff --git a/api/spec/factories/auth_tokens.rb b/api/spec/factories/auth_tokens.rb new file mode 100644 index 0000000..6dd7396 --- /dev/null +++ b/api/spec/factories/auth_tokens.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :auth_token do + jti "MyString" + aud "MyString" + user nil + end +end diff --git a/api/spec/factories/entities.rb b/api/spec/factories/entities.rb new file mode 100644 index 0000000..6573459 --- /dev/null +++ b/api/spec/factories/entities.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :entity do + project + + sequence(:name) { |n| "Entity Name #{n}" } + sequence(:label) { |n| "Entity Label #{n}" } + end +end diff --git a/api/spec/factories/exports.rb b/api/spec/factories/exports.rb new file mode 100644 index 0000000..e406151 --- /dev/null +++ b/api/spec/factories/exports.rb @@ -0,0 +1,4 @@ +FactoryBot.define do + factory :export do + end +end diff --git a/api/spec/factories/fields.rb b/api/spec/factories/fields.rb new file mode 100644 index 0000000..3b10063 --- /dev/null +++ b/api/spec/factories/fields.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :field do + entity + + sequence(:name) { |n| "Field Name #{n}" } + sequence(:label) { |n| "Field Label #{n}" } + data_type :single_line_text + end +end diff --git a/api/spec/factories/key_pairs.rb b/api/spec/factories/key_pairs.rb new file mode 100644 index 0000000..77edeac --- /dev/null +++ b/api/spec/factories/key_pairs.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :key_pair do + project + end +end diff --git a/api/spec/factories/projects.rb b/api/spec/factories/projects.rb new file mode 100644 index 0000000..55b2b92 --- /dev/null +++ b/api/spec/factories/projects.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :project do + sequence(:name) { |n| "Project #{n}" } + + team + end +end diff --git a/api/spec/factories/records.rb b/api/spec/factories/records.rb new file mode 100644 index 0000000..bfda71d --- /dev/null +++ b/api/spec/factories/records.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :record do + entity + end +end diff --git a/api/spec/factories/resources.rb b/api/spec/factories/resources.rb new file mode 100644 index 0000000..d8f1eb0 --- /dev/null +++ b/api/spec/factories/resources.rb @@ -0,0 +1,4 @@ +FactoryBot.define do + factory :resource do + end +end diff --git a/api/spec/factories/restores.rb b/api/spec/factories/restores.rb new file mode 100644 index 0000000..b49e0d5 --- /dev/null +++ b/api/spec/factories/restores.rb @@ -0,0 +1,4 @@ +FactoryBot.define do + factory :restore do + end +end diff --git a/api/spec/factories/team_memberships.rb b/api/spec/factories/team_memberships.rb new file mode 100644 index 0000000..5b25129 --- /dev/null +++ b/api/spec/factories/team_memberships.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :team_membership do + team + user + role :editor + end +end diff --git a/api/spec/factories/teams.rb b/api/spec/factories/teams.rb new file mode 100644 index 0000000..c3412de --- /dev/null +++ b/api/spec/factories/teams.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :team do + name { FFaker::Company.name } + end +end diff --git a/api/spec/factories/users.rb b/api/spec/factories/users.rb new file mode 100755 index 0000000..e33092d --- /dev/null +++ b/api/spec/factories/users.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + sequence(:email) { |n| "user#{n}@example.com" } + + factory :user do + email + first_name { FFaker::Name.first_name } + last_name { FFaker::Name.last_name } + end +end diff --git a/api/spec/interactors/accept_transfer_request_spec.rb b/api/spec/interactors/accept_transfer_request_spec.rb new file mode 100644 index 0000000..9de5446 --- /dev/null +++ b/api/spec/interactors/accept_transfer_request_spec.rb @@ -0,0 +1,72 @@ +require 'rails_helper' + +RSpec.describe AcceptTransferRequest, type: :interactor do + describe '.call' do + before do + @owner = create(:user) + @user = create(:user) + @team = create(:team) + @owner_team_membership = create(:team_membership, team: @team, user: @owner, role: :owner) + @user_team_membership = create(:team_membership, team: @team, user: @user) + end + + context 'with transfer request' do + before do + @transfer_time = Time.zone.today + + @team.request_transfer_to!(@user) + end + + context 'which has not expired' do + before do + @result = AcceptTransferRequest.call(team: @team) + end + + it 'is successful' do + expect(@result).to be_a_success + end + + it 'transfers ownership to new user and sets old owner as manager' do + expect(@user_team_membership.reload.role).to eq('owner') + expect(@owner_team_membership.reload.role).to eq('manager') + end + + it 'resets transfer' do + expect(@result.team.transfer_owner).to be_nil + expect(@result.team.transfer_digest).to be_nil + expect(@result.team.transfer_generated_at).to be_nil + end + end + + context 'which has expired' do + before do + Timecop.freeze(@transfer_time + Team::TRANSFER_EXPIRY_PERIOD + 1.day) + @result = AcceptTransferRequest.call(team: @team) + Timecop.return + end + + it 'is failure' do + expect(@result).to be_a_failure + expect(@result.error).to eq('Your transfer link has either expired or been canceled.') + end + + it 'does not reset transfer' do + expect(@result.team.transfer_owner).not_to be_nil + expect(@result.team.transfer_digest).not_to be_nil + expect(@result.team.transfer_generated_at).not_to be_nil + end + end + end + + context 'without transfer request' do + before do + @result = AcceptTransferRequest.call(team: @team) + end + + it 'is failure' do + expect(@result).to be_a_failure + expect(@result.error).to eq('Your transfer link has either expired or been canceled.') + end + end + end +end diff --git a/api/spec/interactors/authenticate_user_spec.rb b/api/spec/interactors/authenticate_user_spec.rb new file mode 100644 index 0000000..fc7b865 --- /dev/null +++ b/api/spec/interactors/authenticate_user_spec.rb @@ -0,0 +1,59 @@ +require 'rails_helper' + +RSpec.describe AuthenticateUser, type: :interactor do + describe '.call' do + before do + stub_const('ENV', { 'HMAC_SECRET' => '123' }) + @user = create(:user, email: 'test@keepworks.com') + @aud = 'desktop' + @jti = AuthToken.generate_uniq_jti + end + + def generate_token(user_id: nil, exp: nil) + JsonWebToken.encode(user_id: user_id, exp: exp, jti: @jti, aud: @aud) + end + + context 'valid token' do + it 'should suceed and return user' do + AuthToken.create!(jti: @jti, aud: @aud, user: @user) + bearer_token = generate_token(user_id: @user.id, exp: (Time.current + 1.month).to_i) + + context = AuthenticateUser.call(bearer_token: bearer_token) + + expect(context).to be_a_success + expect(context.user).to be_present + end + end + + context 'invalid token' do + it 'should fail and return expired session when token has expired' do + AuthToken.create!(jti: @jti, aud: @aud, user: @user) + bearer_token = generate_token(user_id: @user.id, exp: (Time.current - 1.month).to_i) + + context = AuthenticateUser.call(bearer_token: bearer_token) + + expect(context).to be_a_failure + expect(context.error).to eq 'Your session has expired. Please login again to continue.' + end + + it 'should fail and return expired session when token has been revoked' do + bearer_token = generate_token(user_id: @user.id, exp: (Time.current + 1.month).to_i) + + context = AuthenticateUser.call(bearer_token: bearer_token) + + expect(context).to be_a_failure + expect(context.error).to eq 'Your session has expired. Please login again to continue.' + end + + it 'should fail and return user not found when user is not present' do + AuthToken.create!(jti: @jti, aud: @aud, user: @user) + bearer_token = generate_token(user_id: 'random-id', exp: (Time.current + 1.month).to_i) + + context = AuthenticateUser.call(bearer_token: bearer_token) + + expect(context).to be_a_failure + expect(context.error).to eq 'User not found' + end + end + end +end diff --git a/api/spec/interactors/cancel_transfer_request_spec.rb b/api/spec/interactors/cancel_transfer_request_spec.rb new file mode 100644 index 0000000..d596bf9 --- /dev/null +++ b/api/spec/interactors/cancel_transfer_request_spec.rb @@ -0,0 +1,21 @@ +require 'rails_helper' + +RSpec.describe CancelTransferRequest, type: :interactor do + describe '.call' do + context 'cancel team ownership transfer request' do + it 'is successful' do + team = create(:team) + user = create(:user) + + team.request_transfer_to!(user) + + result = CancelTransferRequest.call!(team: team) + + expect(result).to be_a_success + expect(team.transfer_digest).to be_nil + expect(team.transfer_owner).to be_nil + expect(team.transfer_generated_at).to be_nil + end + end + end +end diff --git a/api/spec/interactors/create_asset_spec.rb b/api/spec/interactors/create_asset_spec.rb new file mode 100644 index 0000000..edd2adb --- /dev/null +++ b/api/spec/interactors/create_asset_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +RSpec.describe CreateAsset, type: :interactor do + describe '.call' do + let(:project) { create(:project) } + + context 'when given valid params' do + before do + valid_params = { + name: "Asset #{project.id}-1", + file: File.open(Rails.root.join('fixtures', 'assets', 'logo.png')) + } + + @result = CreateAsset.call(params: valid_params, project: project) + end + + it 'is successful' do + expect(@result).to be_a_success + expect(@result.asset.name).to eq("Asset #{project.id}-1") + expect(@result.asset.file.storage_key).to eq(:store) + end + end + + context 'when given invalid params' do + it 'is a failure' do + invalid_params = { + name: '' + } + + expect { CreateAsset.call(params: invalid_params, project: project) }.to raise_exception(ActiveRecord::RecordInvalid) + end + end + end +end diff --git a/api/spec/interactors/create_entity_spec.rb b/api/spec/interactors/create_entity_spec.rb new file mode 100644 index 0000000..5bf5884 --- /dev/null +++ b/api/spec/interactors/create_entity_spec.rb @@ -0,0 +1,36 @@ +require 'rails_helper' + +RSpec.describe CreateEntity, type: :interactor do + describe '.call' do + let(:project) { create(:project) } + + context 'when given valid params' do + before do + valid_params = { + name: "Entity Name #{project.id}-1", + label: "Entity Label #{project.id}-1", + singleton: true + } + + @result = CreateEntity.call(params: valid_params, project: project) + end + + it 'is successful' do + expect(@result).to be_a_success + expect(@result.entity.name).to eq("Entity Name #{project.id}-1") + expect(@result.entity.label).to eq("Entity Label #{project.id}-1") + expect(@result.entity.singleton).to eq(true) + end + end + + context 'when given invalid params' do + it 'is a failure' do + invalid_params = { + name: '' + } + + expect { CreateEntity.call(params: invalid_params, project: project) }.to raise_exception(ActiveRecord::RecordInvalid) + end + end + end +end diff --git a/api/spec/interactors/create_key_pair_spec.rb b/api/spec/interactors/create_key_pair_spec.rb new file mode 100644 index 0000000..17488c7 --- /dev/null +++ b/api/spec/interactors/create_key_pair_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +RSpec.describe CreateKeyPair, type: :interactor do + describe '.call' do + let(:project) { create(:project) } + + it 'is successful when given a project' do + result = CreateKeyPair.call(project: project) + + expect(result).to be_a_success + expect(result.key_pair.project_id).to eq(project.id) + expect(result.key_pair.public_key).to be_present + end + end +end diff --git a/api/spec/interactors/create_project_spec.rb b/api/spec/interactors/create_project_spec.rb new file mode 100644 index 0000000..a795aad --- /dev/null +++ b/api/spec/interactors/create_project_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe CreateProject, type: :interactor do + describe '.call' do + let(:team) { create(:team) } + + context 'when given valid params' do + before do + @valid_params = { + team_id: team.id, + name: "Project #{team.id}-1" + } + end + + it 'is successful' do + result = CreateProject.call(params: @valid_params, team: team) + + expect(result).to be_a_success + expect(result.project.name).to eq("Project #{team.id}-1") + end + + it 'creates a key pair for the project' do + result = CreateProject.call(params: @valid_params, team: team) + + expect(result).to be_a_success + expect(result.project.key_pairs.length).to eq(1) + end + end + + context 'when given invalid params' do + it 'is a failure' do + invalid_params = { + team_id: team.id, + name: '' + } + + expect { CreateProject.call(params: invalid_params, team: team) }.to raise_exception(ActiveRecord::RecordInvalid) + end + end + end +end diff --git a/api/spec/interactors/create_team_membership_spec.rb b/api/spec/interactors/create_team_membership_spec.rb new file mode 100644 index 0000000..613a768 --- /dev/null +++ b/api/spec/interactors/create_team_membership_spec.rb @@ -0,0 +1,73 @@ +require 'rails_helper' + +RSpec.describe CreateTeamMembership, type: :interactor do + describe '.call' do + before(:each) do + @team = create(:team) + end + + context 'when invited user already exist' do + before do + @email = generate(:email) + end + + context 'when invited user is confirmed' do + before do + @invited_user = create(:user, email: @email) + end + + context 'when invited user is not part of the team' do + before do + @result = CreateTeamMembership.call(team: @team, params: { + email: @email, + role: 'editor' + }) + end + + it 'is successful' do + expect(@result).to be_a_success + end + + it 'must create a team membership with the given role' do + expect(@result.team_membership).to be_present + expect(@result.team_membership.role).to eq('editor') + end + + it 'must invite the already existing user' do + expect(@result.user.email).to eq(@email) + end + end + + context 'when invited user is already part of the team' do + before do + @invited_user.teams << @team + + @result = CreateTeamMembership.call(team: @team, params: { + email: @email, + role: 'editor' + }) + end + + it 'is failure' do + expect(@result).to be_a_failure + expect(@result.error).to eq('Email is already added to the team.') + end + end + end + end + + context 'when a user is invited with owner role' do + before do + @result = CreateTeamMembership.call(team: @team, params: { + email: generate(:email), + role: 'owner' + }) + end + + it 'is failure' do + expect(@result).to be_a_failure + expect(@result.error).to eq('Role cannot be owner.') + end + end + end +end diff --git a/api/spec/interactors/create_team_spec.rb b/api/spec/interactors/create_team_spec.rb new file mode 100644 index 0000000..7daab34 --- /dev/null +++ b/api/spec/interactors/create_team_spec.rb @@ -0,0 +1,35 @@ +require 'rails_helper' + +RSpec.describe CreateTeam, type: :interactor do + describe '.call' do + context 'when given valid params' do + before do + @current_user = create(:user) + + valid_params = { + team_params: { name: 'Valid Company Name' }, + current_user: @current_user + } + + @result = CreateTeam.call(valid_params) + end + + it 'is successful' do + expect(@result).to be_a_success + end + + it 'must create a team' do + expect(@result.team).to be_present + expect(@result.team.name).to eq('Valid Company Name') + end + + it 'must create a team membership with owner role and normal status' do + team_memberships = @result.team.team_memberships + + expect(team_memberships.count).to eq(1) + expect(team_memberships.first.user_id).to eq(@current_user.id) + expect(team_memberships.first.role).to eq('owner') + end + end + end +end diff --git a/api/spec/interactors/create_transfer_request_spec.rb b/api/spec/interactors/create_transfer_request_spec.rb new file mode 100644 index 0000000..b6cecc1 --- /dev/null +++ b/api/spec/interactors/create_transfer_request_spec.rb @@ -0,0 +1,125 @@ +require 'rails_helper' + +RSpec.describe CreateTransferRequest, type: :interactor do + describe '.call' do + let(:user) { create(:user) } + let(:team) { create(:team) } + + context 'when given valid params' do + before do + @valid_user = create(:user) + + valid_params = { + user_id: @valid_user.id + } + + team.team_memberships.create!( + user: @valid_user, + role: 'manager' + ) + + @result = CreateTransferRequest.call(params: valid_params, team: team) + end + + it 'is successful' do + expect(@result).to be_a_success + end + + it 'generates transfer request' do + expect(@result.team.transfer_owner).to eq(@valid_user) + expect(@result.team.transfer_token).not_to be_nil + expect(@result.team.transfer_generated_at).not_to be_nil + end + end + + context 'when invalid user is given' do + before do + invalid_params = { + user_id: -1 + } + + @result = CreateTransferRequest.call(params: invalid_params, team: team) + end + + it 'is a failure' do + expect(@result).to be_a_failure + expect(@result.error).to eq('The user you have selected does not exist.') + end + + it 'does not generate transfer request' do + expect(@result.team.transfer_owner).to be_nil + expect(@result.team.transfer_token).to be_nil + expect(@result.team.transfer_generated_at).to be_nil + end + end + + context 'when onboarding pending user is given' do + before do + invalid_params = { + user_id: user.id + } + + @result = CreateTransferRequest.call(params: invalid_params, team: team) + end + + it 'is a failure' do + expect(@result).to be_a_failure + end + + it 'does not generate transfer request' do + expect(@result.team.transfer_owner).to be_nil + expect(@result.team.transfer_token).to be_nil + expect(@result.team.transfer_generated_at).to be_nil + end + end + + context 'when user not belonging to the team is given' do + before do + invalid_user = create(:user) + invalid_params = { + user_id: invalid_user.id + } + + @result = CreateTransferRequest.call(params: invalid_params, team: team) + end + + it 'is a failure' do + expect(@result).to be_a_failure + expect(@result.error).to eq('The user you have selected does not belong to this team.') + end + + it 'does not generate transfer request' do + expect(@result.team.transfer_owner).to be_nil + expect(@result.team.transfer_token).to be_nil + expect(@result.team.transfer_generated_at).to be_nil + end + end + + context 'when user is the owner of the team' do + before do + invalid_user = create(:user) + invalid_params = { + user_id: invalid_user.id + } + + team.team_memberships.create!( + user: invalid_user, + role: 'owner' + ) + + @result = CreateTransferRequest.call(params: invalid_params, team: team) + end + + it 'is a failure' do + expect(@result).to be_a_failure + expect(@result.error).to eq('The user you have selected is already the owner of this team.') + end + + it 'does not generate transfer request' do + expect(@result.team.transfer_owner).to be_nil + expect(@result.team.transfer_token).to be_nil + expect(@result.team.transfer_generated_at).to be_nil + end + end + end +end diff --git a/api/spec/interactors/destroy_asset_spec.rb b/api/spec/interactors/destroy_asset_spec.rb new file mode 100644 index 0000000..f4ffe08 --- /dev/null +++ b/api/spec/interactors/destroy_asset_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +RSpec.describe DestroyAsset, type: :interactor do + describe '.call' do + it 'must destroy the asset' do + @result = DestroyAsset.call(asset: create(:asset)) + + expect(@result).to be_a_success + expect(@result.asset).to be_destroyed + end + end +end diff --git a/api/spec/interactors/destroy_entity_spec.rb b/api/spec/interactors/destroy_entity_spec.rb new file mode 100644 index 0000000..419eb4a --- /dev/null +++ b/api/spec/interactors/destroy_entity_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +RSpec.describe DestroyEntity, type: :interactor do + describe '.call' do + it 'must destroy the entity' do + @result = DestroyEntity.call(entity: create(:entity)) + + expect(@result).to be_a_success + expect(@result.entity).to be_destroyed + end + end +end diff --git a/api/spec/interactors/destroy_team_membership_spec.rb b/api/spec/interactors/destroy_team_membership_spec.rb new file mode 100644 index 0000000..42787a3 --- /dev/null +++ b/api/spec/interactors/destroy_team_membership_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' + +RSpec.describe DestroyTeamMembership, type: :interactor do + describe '.call' do + it 'must fail if team membership role is owner' do + result = DestroyTeamMembership.call(team_membership: create(:team_membership, role: :owner)) + + expect(result).to be_a_failure + expect(result.error).to eq('Owner cannot be deleted.') + end + + it 'must destroy the team membership' do + result = DestroyTeamMembership.call(team_membership: create(:team_membership)) + + expect(result).to be_a_success + expect(result.team_membership).to be_destroyed + end + + it 'must clean up pending transfer requests' do + team_membership = create(:team_membership) + team_membership.team.request_transfer_to!(team_membership.user) + + result = DestroyTeamMembership.call(team_membership: team_membership) + + expect(result).to be_a_success + expect(result.team_membership).to be_destroyed + expect(result.team_membership.team.transfer_owner).to be_nil + expect(result.team_membership.team.transfer_digest).to be_nil + expect(result.team_membership.team.transfer_generated_at).to be_nil + end + end +end diff --git a/api/spec/interactors/reject_transfer_request_spec.rb b/api/spec/interactors/reject_transfer_request_spec.rb new file mode 100644 index 0000000..1166f59 --- /dev/null +++ b/api/spec/interactors/reject_transfer_request_spec.rb @@ -0,0 +1,72 @@ +require 'rails_helper' + +RSpec.describe RejectTransferRequest, type: :interactor do + describe '.call' do + before do + @owner = create(:user) + @user = create(:user) + @team = create(:team) + @owner_team_membership = create(:team_membership, team: @team, user: @owner, role: :owner) + @user_team_membership = create(:team_membership, team: @team, user: @user, role: :editor) + end + + context 'with transfer request' do + before do + @transfer_time = Time.zone.today + + @team.request_transfer_to!(@user) + end + + context 'which has not expired' do + before do + @result = RejectTransferRequest.call(team: @team) + end + + it 'is successful' do + expect(@result).to be_a_success + end + + it 'does not transfer ownership and roles stay the same' do + expect(@owner_team_membership.reload.role).to eq('owner') + expect(@user_team_membership.reload.role).to eq('editor') + end + + it 'resets transfer' do + expect(@result.team.transfer_owner).to be_nil + expect(@result.team.transfer_digest).to be_nil + expect(@result.team.transfer_generated_at).to be_nil + end + end + + context 'which has expired' do + before do + Timecop.freeze(@transfer_time + Team::TRANSFER_EXPIRY_PERIOD + 1.day) + @result = RejectTransferRequest.call(team: @team) + Timecop.return + end + + it 'is failure' do + expect(@result).to be_a_failure + expect(@result.error).to eq('Your transfer link has either expired or been canceled.') + end + + it 'does not reset transfer' do + expect(@result.team.transfer_owner).not_to be_nil + expect(@result.team.transfer_digest).not_to be_nil + expect(@result.team.transfer_generated_at).not_to be_nil + end + end + end + + context 'without transfer request' do + before do + @result = RejectTransferRequest.call(team: @team) + end + + it 'is failure' do + expect(@result).to be_a_failure + expect(@result.error).to eq('Your transfer link has either expired or been canceled.') + end + end + end +end diff --git a/api/spec/interactors/revoke_key_pair_spec.rb b/api/spec/interactors/revoke_key_pair_spec.rb new file mode 100644 index 0000000..60d3821 --- /dev/null +++ b/api/spec/interactors/revoke_key_pair_spec.rb @@ -0,0 +1,19 @@ +require 'rails_helper' + +RSpec.describe RevokeKeyPair, type: :interactor do + describe '.call' do + it 'must expire the key_pair' do + @result = RevokeKeyPair.call(key_pair: create(:key_pair)) + + expect(@result).to be_a_success + expect(@result.key_pair.expires_at).to be_present + end + + it 'triggers #revoke! on the key_pair' do + key_pair = double(key_pair) + + expect(key_pair).to receive(:revoke!) + RevokeKeyPair.call(key_pair: key_pair) + end + end +end diff --git a/api/spec/interactors/sso_callback_spec.rb b/api/spec/interactors/sso_callback_spec.rb new file mode 100644 index 0000000..3cbbed3 --- /dev/null +++ b/api/spec/interactors/sso_callback_spec.rb @@ -0,0 +1,66 @@ +require 'rails_helper' + +RSpec.describe SsoCallback, type: :interactor do + describe '.call' do + before do + stub_const('ENV', { 'CLAY_SSO_SECRET' => '123', 'CLAY_SSO_URL' => 'http://localhost:3000'}) + @user = create(:user, email: 'test@keepworks.com') + @user_agent = 'Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.17 Safari/537.36' + end + + def generate_nonce(time = Time.current) + nonce = AuthNonce.generate_uniq_nonce + @nonce = AuthNonce.create!( + nonce: nonce, + expires_at: time + AuthNonce::NONCE_EXPIRY_PERIOD + ).nonce + end + + def build_payload + sso_raw = "nonce=#{@nonce}&email=test@keepworks.com&external_id=1" + b64sso = Base64.encode64(sso_raw) + @sso = CGI.escape(b64sso) + @sig = OpenSSL::HMAC.hexdigest('SHA256', ENV['CLAY_SSO_SECRET'], b64sso) + end + + it 'should suceed and return token' do + generate_nonce + build_payload + + params = { sso: @sso, sig: @sig } + context = SsoCallback.call( + params: params, + user_agent: @user_agent + ) + + expect(context).to be_a_success + expect(context.sso_payload[:token]).to be_present + end + + it 'should throw an error if nonce has expired' do + generate_nonce(Time.current - 10.minutes) + build_payload + + params = { sso: @sso, sig: @sig } + + expect { SsoCallback.call(params: params, user_agent: @user_agent) }.to raise_error(Exceptions::Unauthorized, 'Request Expired') + end + + it 'should throw an error if signature mismatches' do + generate_nonce + build_payload + @sig = OpenSSL::HMAC.hexdigest('SHA256', 'random secret', 'random payload') + params = { sso: @sso, sig: @sig } + + expect { SsoCallback.call(params: params, user_agent: @user_agent) }.to raise_error(Exceptions::Unauthorized, 'Invalid Signature') + end + + it 'should create a record in AuthToken table' do + generate_nonce + build_payload + params = { sso: @sso, sig: @sig } + + expect { SsoCallback.call(params: params, user_agent: @user_agent) }.to change { AuthToken.count }.by(1) + end + end +end diff --git a/api/spec/interactors/sso_login_spec.rb b/api/spec/interactors/sso_login_spec.rb new file mode 100644 index 0000000..32fd01a --- /dev/null +++ b/api/spec/interactors/sso_login_spec.rb @@ -0,0 +1,24 @@ +require 'rails_helper' + +RSpec.describe SsoLogin, type: :interactor do + describe '.call' do + before do + stub_const('ENV', { 'CLAY_SSO_SECRET' => '123', 'CLAY_SSO_URL' => 'http://localhost:3000' }) + @nonce_expire_in = 5 + end + + it 'should succeed and return the sso url' do + context = SsoLogin.call + + expect(context).to be_a_success + expect(context.sso_payload[:sso_url]).to be_present + end + + it 'should create a valid nonce record in AuthNonce table' do + context = SsoLogin.call + nonce = AuthNonce.find_by!(nonce: context.nonce) + expect(nonce.expired?).to be_falsey + expect { SsoLogin.call }.to change { AuthNonce.count }.by(1) + end + end +end diff --git a/api/spec/interactors/update_asset_spec.rb b/api/spec/interactors/update_asset_spec.rb new file mode 100644 index 0000000..e4b51f2 --- /dev/null +++ b/api/spec/interactors/update_asset_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' + +RSpec.describe UpdateAsset, type: :interactor do + describe '.call' do + let(:asset) { create(:asset, name: 'Test Asset 1') } + + context 'when given valid params' do + it 'is successful' do + valid_params = { + name: 'Test Asset 2', + file: File.open(Rails.root.join('fixtures', 'assets', 'logo.png')) + } + + result = UpdateAsset.call(params: valid_params, asset: asset) + + expect(result).to be_a_success + expect(result.asset.name).to eq('Test Asset 2') + expect(result.asset.file.storage_key).to eq(:store) + end + end + + context 'when given invalid params' do + it 'is a failure' do + invalid_params = { + name: '' + } + + expect { UpdateAsset.call(params: invalid_params, asset: asset) }.to raise_exception(ActiveRecord::RecordInvalid) + end + end + end +end diff --git a/api/spec/interactors/update_entity_spec.rb b/api/spec/interactors/update_entity_spec.rb new file mode 100644 index 0000000..ccc32e3 --- /dev/null +++ b/api/spec/interactors/update_entity_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' + +RSpec.describe UpdateEntity, type: :interactor do + describe '.call' do + let(:entity) { create(:entity) } + + context 'when given valid params' do + it 'is successful' do + valid_params = { + name: 'Test Entity 2', + label: 'Test Entity 2' + } + + result = UpdateEntity.call(params: valid_params, entity: entity) + + expect(result).to be_a_success + expect(result.entity.name).to eq('Test Entity 2') + end + end + + context 'when given invalid params' do + it 'is a failure' do + invalid_params = { + name: '' + } + + expect { UpdateEntity.call(params: invalid_params, entity: entity) }.to raise_exception(ActiveRecord::RecordInvalid) + end + end + end +end diff --git a/api/spec/interactors/update_project_spec.rb b/api/spec/interactors/update_project_spec.rb new file mode 100644 index 0000000..2e85c12 --- /dev/null +++ b/api/spec/interactors/update_project_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' + +RSpec.describe UpdateProject, type: :interactor do + describe '.call' do + let(:project) { create(:project, name: 'Test Project 1') } + + context 'when given valid params' do + it 'is successful' do + valid_params = { + name: 'Test Project 2' + } + + result = UpdateProject.call(params: valid_params, project: project) + + expect(result).to be_a_success + expect(result.project.name).to eq('Test Project 2') + expect(result.project.team).to eq(project.team) + end + end + + context 'when given invalid params' do + it 'is a failure' do + invalid_params = { + name: '' + } + + expect { UpdateProject.call(params: invalid_params, project: project) }.to raise_exception(ActiveRecord::RecordInvalid) + end + end + end +end diff --git a/api/spec/interactors/update_team_membership_spec.rb b/api/spec/interactors/update_team_membership_spec.rb new file mode 100644 index 0000000..3e3fba7 --- /dev/null +++ b/api/spec/interactors/update_team_membership_spec.rb @@ -0,0 +1,42 @@ +require 'rails_helper' + +RSpec.describe UpdateTeamMembership, type: :interactor do + describe '.call' do + before(:each) do + @team_membership = create(:team_membership, role: :editor) + end + + context 'when the new role is owner' do + before do + @params = { + role: 'owner' + } + @result = UpdateTeamMembership.call(team_membership: @team_membership, params: @params) + end + + it 'is failure' do + expect(@result).to be_a_failure + expect(@result.error).to eq('Role cannot be owner.') + end + end + + context 'when the new role is not owner' do + before do + @params = { + role: 'editor' + } + + @result = UpdateTeamMembership.call(team_membership: @team_membership, params: @params) + end + + it 'is success' do + expect(@result).to be_a_success + end + + it 'must update the team membership with the give role' do + expect(@result.team_membership).to be_present + expect(@result.team_membership.role).to eq('editor') + end + end + end +end diff --git a/api/spec/interactors/update_team_spec.rb b/api/spec/interactors/update_team_spec.rb new file mode 100644 index 0000000..abc4241 --- /dev/null +++ b/api/spec/interactors/update_team_spec.rb @@ -0,0 +1,30 @@ +require 'rails_helper' + +RSpec.describe UpdateTeam, type: :interactor do + describe '.call' do + let(:team) { create(:team, name: 'Team 1') } + + context 'when given valid params' do + it 'is successful' do + valid_params = { + name: 'Team 2' + } + + result = UpdateTeam.call(params: valid_params, team: team) + + expect(result).to be_a_success + expect(result.team.name).to eq('Team 2') + end + end + + context 'when given invalid params' do + it 'is a failure' do + invalid_params = { + name: '' + } + + expect { UpdateTeam.call(params: invalid_params, team: team) }.to raise_exception(ActiveRecord::RecordInvalid) + end + end + end +end diff --git a/api/spec/interactors/update_user_spec.rb b/api/spec/interactors/update_user_spec.rb new file mode 100644 index 0000000..b0572b5 --- /dev/null +++ b/api/spec/interactors/update_user_spec.rb @@ -0,0 +1,51 @@ +require 'rails_helper' + +RSpec.describe UpdateUser, type: :interactor do + describe '.call' do + context 'when valid params are given' do + it 'is successful' do + user = create(:user, first_name: 'first name', last_name: 'last name') + + valid_params = { + first_name: 'new first name', + last_name: 'new last name', + profile_picture: File.open(Rails.root.join('fixtures', 'assets', 'logo.png')) + } + + result = UpdateUser.call(params: valid_params, user: user) + + expect(result).to be_a_success + expect(result.user.first_name).to eq('new first name') + expect(result.user.last_name).to eq('new last name') + expect(result.user.profile_picture.storage_key).to eq(:store) + end + end + + context 'when no params are given' do + it 'does not change value' do + user = create(:user, first_name: 'first name', last_name: 'last name') + + params = {} + + result = UpdateUser.call(params: params, user: user) + + expect(result.user.first_name).to eq('first name') + expect(result.user.last_name).to eq('last name') + expect(result.user.profile_picture).to eq(nil) + end + end + + context 'when first name and last name are empty' do + it 'is a failure' do + user = create(:user, first_name: 'first name', last_name: 'last name') + + invalid_params = { + first_name: '', + last_name: '' + } + + expect { UpdateUser.call(params: invalid_params, user: user) }.to raise_exception(ActiveRecord::RecordInvalid) + end + end + end +end diff --git a/api/spec/lib/app_url_spec.rb b/api/spec/lib/app_url_spec.rb new file mode 100644 index 0000000..0ff99b6 --- /dev/null +++ b/api/spec/lib/app_url_spec.rb @@ -0,0 +1,57 @@ +require 'rails_helper' + +RSpec.describe AppUrl do + describe '.build' do + context 'in development' do + around do |example| + ClimateControl.modify APP_HOST: 'pigeonapp-dev.io', APP_PORT: '8080' do + example.run + end + end + + it 'returns URL with http scheme and port' do + expect(AppUrl.build).to eq('http://pigeonapp-dev.io:8080') + end + + it 'appends path' do + expect(AppUrl.build('/test/123')).to eq('http://pigeonapp-dev.io:8080/test/123') + end + + it 'appends query' do + expect(AppUrl.build('', foo: 'bar')).to eq('http://pigeonapp-dev.io:8080?foo=bar') + end + + it 'appends both path and query' do + expect(AppUrl.build('/test/123', foo: 'bar')).to eq('http://pigeonapp-dev.io:8080/test/123?foo=bar') + end + + it 'fails when given a path without preceding slash' do + expect { AppUrl.build('test/123') }.to raise_exception(URI::InvalidComponentError) + end + end + + context 'in production without https' do + around do |example| + ClimateControl.modify APP_HOST: 'pigeonapp.io', APP_PORT: '80' do + example.run + end + end + + it 'returns URL with http scheme and no port' do + expect(AppUrl.build).to eq('http://pigeonapp.io') + end + end + + context 'in production with https' do + around do |example| + ClimateControl.modify APP_HOST: 'pigeonapp.io', APP_PORT: '443' do + example.run + end + end + + it 'returns URL with https scheme and no port' do + expect(AppUrl.build).to eq('https://pigeonapp.io') + end + end + end +end diff --git a/api/spec/models/asset_spec.rb b/api/spec/models/asset_spec.rb new file mode 100644 index 0000000..4bb50d8 --- /dev/null +++ b/api/spec/models/asset_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +RSpec.describe Asset, type: :model do + describe 'relationships' do + it { is_expected.to belong_to(:project) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:file) } + end +end diff --git a/api/spec/models/auth_nonce_spec.rb b/api/spec/models/auth_nonce_spec.rb new file mode 100644 index 0000000..a1ac9aa --- /dev/null +++ b/api/spec/models/auth_nonce_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe AuthNonce, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/api/spec/models/auth_token_spec.rb b/api/spec/models/auth_token_spec.rb new file mode 100644 index 0000000..d795613 --- /dev/null +++ b/api/spec/models/auth_token_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe AuthToken, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/api/spec/models/entity_spec.rb b/api/spec/models/entity_spec.rb new file mode 100644 index 0000000..2ea93f4 --- /dev/null +++ b/api/spec/models/entity_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +RSpec.describe Entity, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:project) } + it { is_expected.to have_many(:fields) } + it { is_expected.to have_many(:relationships) } + it { is_expected.to have_many(:records) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:label) } + it { is_expected.to validate_presence_of(:name) } + end +end diff --git a/api/spec/models/export_spec.rb b/api/spec/models/export_spec.rb new file mode 100644 index 0000000..432e1ab --- /dev/null +++ b/api/spec/models/export_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Export, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/api/spec/models/field_spec.rb b/api/spec/models/field_spec.rb new file mode 100644 index 0000000..0d1f257 --- /dev/null +++ b/api/spec/models/field_spec.rb @@ -0,0 +1,16 @@ +require 'rails_helper' + +RSpec.describe Field, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:entity).optional } + it { is_expected.to have_many(:relationships) } + it { is_expected.to have_many(:properties) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:label) } + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:data_type) } + it { is_expected.to validate_presence_of(:position) } + end +end diff --git a/api/spec/models/key_pair_spec.rb b/api/spec/models/key_pair_spec.rb new file mode 100644 index 0000000..943d294 --- /dev/null +++ b/api/spec/models/key_pair_spec.rb @@ -0,0 +1,54 @@ +require 'rails_helper' + +RSpec.describe KeyPair, type: :model do + describe 'validations' do + it { is_expected.to validate_uniqueness_of(:public_key) } + end + + describe 'callbacks' do + it 'sets public_key on creation' do + key_pair = create(:key_pair, public_key: nil) + + expect(key_pair.public_key).to be_present + end + end + + describe '.generate_public_key' do + it 'returns a key 24 characters long' do + key = KeyPair.generate_public_key + + expect(key.length).to eq(24) + end + end + + describe '#revoke!' do + it 'sets expires_at to now' do + key_pair = create(:key_pair, expires_at: nil) + + revoke_time = Time.current + 5.days + + Timecop.freeze(revoke_time) + + key_pair.revoke! + + expect(key_pair.expires_at).to eq(revoke_time) + + Timecop.return + end + + it 'does not overwrite expires_at if already set' do + previous_revoke_time = Time.current + revoke_time = Time.current + 5.days + + key_pair = create(:key_pair, expires_at: previous_revoke_time) + + Timecop.freeze(revoke_time) + + key_pair.revoke! + + expect(key_pair.expires_at).to eq(previous_revoke_time) + + Timecop.return + end + end +end diff --git a/api/spec/models/locale_spec.rb b/api/spec/models/locale_spec.rb new file mode 100644 index 0000000..a8f4da1 --- /dev/null +++ b/api/spec/models/locale_spec.rb @@ -0,0 +1,11 @@ +require 'rails_helper' + +RSpec.describe Locale, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:project) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:language) } + end +end diff --git a/api/spec/models/project_spec.rb b/api/spec/models/project_spec.rb new file mode 100644 index 0000000..77eee26 --- /dev/null +++ b/api/spec/models/project_spec.rb @@ -0,0 +1,18 @@ +require 'rails_helper' + +RSpec.describe Project, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:team) } + it { is_expected.to have_many(:assets).dependent(:destroy) } + it { is_expected.to have_many(:key_pairs).dependent(:destroy) } + it { is_expected.to have_many(:entities).dependent(:destroy) } + it { is_expected.to have_many(:locales).dependent(:destroy) } + it { is_expected.to have_many(:resources).dependent(:destroy) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:uid) } + it { is_expected.to validate_uniqueness_of(:uid) } + end +end diff --git a/api/spec/models/property_spec.rb b/api/spec/models/property_spec.rb new file mode 100644 index 0000000..b3762c5 --- /dev/null +++ b/api/spec/models/property_spec.rb @@ -0,0 +1,8 @@ +require 'rails_helper' + +RSpec.describe Property, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:record).optional } + it { is_expected.to belong_to(:field) } + end +end diff --git a/api/spec/models/record_spec.rb b/api/spec/models/record_spec.rb new file mode 100644 index 0000000..4ad0ed1 --- /dev/null +++ b/api/spec/models/record_spec.rb @@ -0,0 +1,13 @@ +require 'rails_helper' + +RSpec.describe Record, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:entity) } + + it { is_expected.to have_many(:properties) } + end + + describe 'macros' do + it { is_expected.to accept_nested_attributes_for(:properties) } + end +end diff --git a/api/spec/models/relationship_spec.rb b/api/spec/models/relationship_spec.rb new file mode 100644 index 0000000..d1afae0 --- /dev/null +++ b/api/spec/models/relationship_spec.rb @@ -0,0 +1,10 @@ +require 'rails_helper' + +RSpec.describe Relationship, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:entity) } + it { is_expected.to belong_to(:field) } + it { is_expected.to belong_to(:linked_entity).class_name('Entity').with_foreign_key(:linked_entity_id) } + it { is_expected.to belong_to(:linked_field).class_name('Field').with_foreign_key(:linked_field_id) } + end +end diff --git a/api/spec/models/resource_spec.rb b/api/spec/models/resource_spec.rb new file mode 100644 index 0000000..3aae9c4 --- /dev/null +++ b/api/spec/models/resource_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +RSpec.describe Resource, type: :model do + describe 'relationships' do + it { is_expected.to belong_to(:project) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:file) } + end +end diff --git a/api/spec/models/restore_spec.rb b/api/spec/models/restore_spec.rb new file mode 100644 index 0000000..45ffbb0 --- /dev/null +++ b/api/spec/models/restore_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Restore, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/api/spec/models/team_membership_spec.rb b/api/spec/models/team_membership_spec.rb new file mode 100644 index 0000000..1e9b6b0 --- /dev/null +++ b/api/spec/models/team_membership_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +RSpec.describe TeamMembership, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:user) } + it { is_expected.to belong_to(:team) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:role) } + end +end diff --git a/api/spec/models/team_spec.rb b/api/spec/models/team_spec.rb new file mode 100644 index 0000000..cee0f61 --- /dev/null +++ b/api/spec/models/team_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +RSpec.describe Team, type: :model do + it_behaves_like 'transferable' + + describe 'associations' do + it { is_expected.to have_many(:team_memberships) } + it { is_expected.to have_many(:users).through(:team_memberships) } + it { is_expected.to have_many(:projects) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:name) } + end +end diff --git a/api/spec/models/user_spec.rb b/api/spec/models/user_spec.rb new file mode 100644 index 0000000..0cfe1f4 --- /dev/null +++ b/api/spec/models/user_spec.rb @@ -0,0 +1,17 @@ +require 'rails_helper' + +RSpec.describe User, type: :model do + describe 'associations' do + it { is_expected.to have_many(:team_memberships) } + it { is_expected.to have_many(:transferable_teams).class_name('Team').dependent(:nullify) } + + it { is_expected.to have_many(:teams).through(:team_memberships) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:email) } + it { is_expected.to validate_uniqueness_of(:email).case_insensitive } + it { is_expected.to validate_presence_of(:first_name) } + it { is_expected.to validate_presence_of(:last_name) } + end +end diff --git a/api/spec/policies/asset_policy_spec.rb b/api/spec/policies/asset_policy_spec.rb new file mode 100644 index 0000000..3515041 --- /dev/null +++ b/api/spec/policies/asset_policy_spec.rb @@ -0,0 +1,14 @@ +require 'rails_helper' + +RSpec.describe AssetPolicy do + include_context 'policy' + + before do + @project = create(:project, team: @team) + @asset = create(:asset, project: @project) + end + + subject { described_class.new(@user, @asset) } + + it_permits :manager, [:update, :destroy] +end diff --git a/api/spec/policies/entity_policy_spec.rb b/api/spec/policies/entity_policy_spec.rb new file mode 100644 index 0000000..823f71d --- /dev/null +++ b/api/spec/policies/entity_policy_spec.rb @@ -0,0 +1,14 @@ +require 'rails_helper' + +RSpec.describe EntityPolicy do + include_context 'policy' + + before do + @project = create(:project, team: @team) + @entity = create(:entity, project: @project) + end + + subject { described_class.new(@user, @entity) } + + it_permits :developer, [:view, :update, :destroy, :create_field] +end diff --git a/api/spec/policies/field_policy_spec.rb b/api/spec/policies/field_policy_spec.rb new file mode 100644 index 0000000..1e65c73 --- /dev/null +++ b/api/spec/policies/field_policy_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +RSpec.describe FieldPolicy do + include_context 'policy' + + before do + @project = create(:project, team: @team) + @entity = create(:entity, project: @project) + @field = create(:field, entity: @entity) + end + + subject { described_class.new(@user, @field) } + + it_permits :developer, [:view, :update, :destroy] +end diff --git a/api/spec/policies/key_pair_policy_spec.rb b/api/spec/policies/key_pair_policy_spec.rb new file mode 100644 index 0000000..f953b82 --- /dev/null +++ b/api/spec/policies/key_pair_policy_spec.rb @@ -0,0 +1,14 @@ +require 'rails_helper' + +RSpec.describe KeyPairPolicy do + include_context 'policy' + + before do + @project = create(:project, team: @team) + @key_pair = create(:key_pair, project: @project) + end + + subject { described_class.new(@user, @key_pair) } + + it_permits :developer, [:revoke] +end diff --git a/api/spec/policies/project_policy_spec.rb b/api/spec/policies/project_policy_spec.rb new file mode 100644 index 0000000..a8f4e78 --- /dev/null +++ b/api/spec/policies/project_policy_spec.rb @@ -0,0 +1,14 @@ +require 'rails_helper' + +RSpec.describe ProjectPolicy do + include_context 'policy' + + before do + @project = create(:project, team: @team) + end + + subject { described_class.new(@user, @project) } + + it_permits :editor, [:view, :view_assets, :view_exports, :view_imports, :view_key_pairs, :view_entities, :create_asset, :create_key_pair, :create_entity] + it_permits :manager, [:update] +end diff --git a/api/spec/policies/record_policy_spec.rb b/api/spec/policies/record_policy_spec.rb new file mode 100644 index 0000000..67c18a5 --- /dev/null +++ b/api/spec/policies/record_policy_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +RSpec.describe RecordPolicy do + include_context 'policy' + + before do + @project = create(:project, team: @team) + @entity = create(:entity, project: @project) + @record = create(:record, entity: @entity) + end + + subject { described_class.new(@user, @field) } + + it_permits :editor, [:view, :update, :destroy] +end diff --git a/api/spec/policies/team_membership_policy_spec.rb b/api/spec/policies/team_membership_policy_spec.rb new file mode 100644 index 0000000..0fba47d --- /dev/null +++ b/api/spec/policies/team_membership_policy_spec.rb @@ -0,0 +1,9 @@ +require 'rails_helper' + +RSpec.describe TeamMembershipPolicy, type: :policy do + include_context 'policy' + + subject { described_class.new(@user) } + + it_permits :manager, [:update, :destroy] +end diff --git a/api/spec/policies/team_policy_spec.rb b/api/spec/policies/team_policy_spec.rb new file mode 100644 index 0000000..4f68f73 --- /dev/null +++ b/api/spec/policies/team_policy_spec.rb @@ -0,0 +1,11 @@ +require 'rails_helper' + +RSpec.describe TeamPolicy, type: :policy do + include_context 'policy' + + subject { described_class.new(@user, @team) } + + it_permits :editor, [:view, :view_team_memberships, :view_projects, :accept_transfer_request, :reject_transfer_request] + it_permits :manager, [:create_team_membership, :create_project, :export_project, :import_project] + it_permits :owner, [:update, :destroy, :create_transfer_request, :cancel_transfer_request] +end diff --git a/api/spec/rails_helper.rb b/api/spec/rails_helper.rb new file mode 100755 index 0000000..ed95d82 --- /dev/null +++ b/api/spec/rails_helper.rb @@ -0,0 +1,66 @@ +# This file is copied to spec/ when you run 'rails generate rspec:install' +require 'spec_helper' +ENV['RAILS_ENV'] ||= 'test' +require File.expand_path('../config/environment', __dir__) +# Prevent database truncation if the environment is production +abort('The Rails environment is running in production mode!') if Rails.env.production? +require 'rspec/rails' +# Add additional requires below this line. Rails is not loaded until this point! + +# Requires supporting ruby files with custom matchers and macros, etc, in +# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are +# run as spec files by default. This means that files in spec/support that end +# in _spec.rb will both be required and run as specs, causing the specs to be +# run twice. It is recommended that you do not name files matching this glob to +# end with _spec.rb. You can configure this pattern with the --pattern +# option on the command line or in ~/.rspec, .rspec or `.rspec-local`. +# +# The following line is provided for convenience purposes. It has the downside +# of increasing the boot-up time by auto-requiring all files in the support +# directory. Alternatively, in the individual `*_spec.rb` files, manually +# require only the support files necessary. +# +Dir[Rails.root.join('spec', 'support', '**', '*.rb')].each { |f| require f } + +# Checks for pending migrations and applies them before tests are run. +# If you are not using ActiveRecord, you can remove this line. +ActiveRecord::Migration.maintain_test_schema! + +RSpec.configure do |config| + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures + config.fixture_path = "#{::Rails.root}/spec/fixtures" + + # If you're not using ActiveRecord, or you'd prefer not to run each of your + # examples within a transaction, remove the following line or assign false + # instead of true. + config.use_transactional_fixtures = true + + # RSpec Rails can automatically mix in different behaviours to your tests + # based on their file location, for example enabling you to call `get` and + # `post` in specs under `spec/controllers`. + # + # You can disable this behaviour by removing the line below, and instead + # explicitly tag your specs with their type, e.g.: + # + # RSpec.describe UsersController, :type => :controller do + # # ... + # end + # + # The different available types are documented in the features, such as in + # https://relishapp.com/rspec/rspec-rails/docs + config.infer_spec_type_from_file_location! + + # Filter lines from Rails gems in backtraces. + config.filter_rails_from_backtrace! + # arbitrary gems may also be filtered via: + # config.filter_gems_from_backtrace("gem name") + + config.include FactoryBot::Syntax::Methods +end + +Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.test_framework :rspec + with.library :rails + end +end diff --git a/api/spec/spec_helper.rb b/api/spec/spec_helper.rb new file mode 100755 index 0000000..bf19028 --- /dev/null +++ b/api/spec/spec_helper.rb @@ -0,0 +1,109 @@ +# This file was generated by the `rails generate rspec:install` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration + +require 'pundit/rspec' +require 'webmock/rspec' +require 'simplecov' + +WebMock.disable_net_connect!(allow_localhost: true) + +SimpleCov.start 'rails' do + add_group 'Interactors', 'app/interactors' + add_group 'Policies', 'app/policies' + add_group 'Notifiers', 'app/notifiers' + add_group 'Uploaders', 'app/uploaders' + add_group 'Resolvers', 'app/graphql/resolvers' + add_group 'Mutators', 'app/graphql/mutators' +end + +RSpec.configure do |config| + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + expectations.syntax = :expect + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + + # This allows you to limit a spec run to individual examples or groups + # you care about by tagging them with `:focus` metadata. When nothing + # is tagged with `:focus`, all examples get run. RSpec also provides + # aliases for `it`, `describe`, and `context` that include `:focus` + # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + config.filter_run_when_matching :focus + + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = 'spec/examples.txt' + + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. For more details, see: + # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ + # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ + # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode + config.disable_monkey_patching! + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = 'doc' + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed +end diff --git a/api/spec/support/concerns/transferable.rb b/api/spec/support/concerns/transferable.rb new file mode 100644 index 0000000..9310342 --- /dev/null +++ b/api/spec/support/concerns/transferable.rb @@ -0,0 +1,130 @@ +require 'rails_helper' + +RSpec.shared_examples_for 'transferable' do + let(:model) { described_class } + + before do + @record = create(model.to_s.underscore.to_sym) + @user = create(:user) + end + + describe '.request_transfer_to!' do + context 'when user is given' do + it 'generates transfer request' do + @record.request_transfer_to!(@user) + + expect(@record.transfer_digest).not_to be_nil + expect(@record.transfer_generated_at).not_to be_nil + expect(@record.transfer_owner).to eq(@user) + end + end + + context 'when new user is given in an active transfer request' do + it 'generates new transfer request' do + @record.request_transfer_to!(@user) + + old_record = @record.clone + old_record.freeze + + user1 = create(:user) + + @record.request_transfer_to!(user1) + + expect(@record.transfer_digest).not_to be_nil + expect(@record.transfer_generated_at).not_to be_nil + expect(@record.transfer_owner).to eq(user1) + expect(@record.transfer_digest).not_to eq(old_record.transfer_digest) + expect(@record.transfer_generated_at).not_to eq(old_record.transfer_generated_at) + end + end + end + + describe '.reset_transfer!' do + context 'when active transfer request is present' do + it 'resets transfer request' do + @record.transfer_digest = @record.class.generate_transfer_digest + @record.transfer_generated_at = Time.zone.now + @record.transfer_owner = @user + + @record.reset_transfer! + + expect(@record.transfer_digest).to be_nil + expect(@record.transfer_generated_at).to be_nil + expect(@record.transfer_owner).to be_nil + end + end + end + + describe '.transfer_expired?' do + context 'when no active transfer request is present' do + it 'returns false' do + expect(@record.transfer_expired?).to eq(false) + end + end + + context 'when active transfer request is present' do + before do + @record.transfer_generated_at = Time.zone.now + @record.transfer_owner = @user + @creation_time = Time.zone.now + end + + it 'returns false when transfer period is live' do + expect(@record.transfer_expired?).to eq(false) + end + + it 'returns true when transfer period is equal to transfer expiry period ' do + Timecop.freeze(@creation_time + model::TRANSFER_EXPIRY_PERIOD) + + is_transfer_expired = @record.transfer_expired? + + Timecop.return + + expect(is_transfer_expired).to be(true) + end + + it 'returns false when transfer period is less than transfer expiry period' do + Timecop.freeze(@creation_time + model::TRANSFER_EXPIRY_PERIOD - 1.second) + + is_transfer_expired = @record.transfer_expired? + + Timecop.return + + expect(is_transfer_expired).to be(false) + end + end + end + + describe '.transfer_requested?' do + context 'when transfer request is valid' do + it 'returns true' do + @record.transfer_generated_at = Time.zone.now + @record.transfer_owner = @user + + expect(@record.transfer_requested?).to eq(true) + end + end + + context 'when transfer request is not present' do + it 'returns false' do + expect(@record.transfer_requested?).to eq(false) + end + end + + context 'when transfer request is expired' do + it 'returns false' do + @record.transfer_generated_at = Time.zone.now + @record.transfer_owner = @user + creation_time = Time.zone.now + + Timecop.freeze(creation_time + model::TRANSFER_EXPIRY_PERIOD) + + transfer_requested = @record.transfer_requested? + + Timecop.return + + expect(transfer_requested).to be(false) + end + end + end +end diff --git a/api/spec/support/geocoder_helper.rb b/api/spec/support/geocoder_helper.rb new file mode 100755 index 0000000..5ef345a --- /dev/null +++ b/api/spec/support/geocoder_helper.rb @@ -0,0 +1,22 @@ +require 'geocoder/results/freegeoip' + +RSpec.shared_context 'Geocoder' do + let(:office_ip_address) { '106.51.100.36' } + + def stub_geocoder_search + geocoder_response = Geocoder::Result::Freegeoip.new( + 'ip' => '106.51.100.36', + 'country_code' => 'IN', + 'country_name' => 'India', + 'region_code' => 'KA', + 'region_name' => 'Karnataka', + 'city' => 'Bengaluru', + 'zip_code' => '', + 'time_zone' => 'Asia/Kolkata', + 'latitude' => 2.9833, + 'longitude' => 7.5833 + ) + + allow(Geocoder).to receive(:search).and_return([geocoder_response]) + end +end diff --git a/api/spec/support/policy.rb b/api/spec/support/policy.rb new file mode 100644 index 0000000..4ed03ca --- /dev/null +++ b/api/spec/support/policy.rb @@ -0,0 +1,22 @@ +ROLES = [:guest, :editor, :developer, :manager, :owner].freeze + +RSpec.shared_context 'policy' do + before do + @user = create(:user) + @team = create(:team) + end + + def self.it_permits(role, actions) + ROLES.each_with_index do |current_role, index| + role_index = ROLES.find_index(role) + + is_granted = index >= role_index if role_index.present? + + it "#{is_granted ? 'grants' : 'denies'} #{actions} for #{current_role}" do + create(:team_membership, user: @user, team: @team, role: current_role) if current_role != :guest + + is_granted ? permit_actions(actions) : forbid_actions(actions) + end + end + end +end diff --git a/api/tmp/.keep b/api/tmp/.keep new file mode 100755 index 0000000..e69de29 diff --git a/api/vendor/.keep b/api/vendor/.keep new file mode 100755 index 0000000..e69de29 diff --git a/ui/.babelrc b/ui/.babelrc new file mode 100644 index 0000000..4f47cac --- /dev/null +++ b/ui/.babelrc @@ -0,0 +1,21 @@ +{ + "plugins": [ + "react-hot-loader/babel", + + // autobinding functions in ES6 classes + "babel-plugin-transform-class-properties", + + // support rest/spread properties for objects + "transform-object-rest-spread", + + // optimize lodash size by cherry-picking only used modules + "lodash", + + // allow dynamic importing of large external libraries + "syntax-dynamic-import" + ], + "presets": [ + ["es2015", { "modules": false }], + "react" + ] +} diff --git a/ui/.env.example b/ui/.env.example new file mode 100644 index 0000000..65b323b --- /dev/null +++ b/ui/.env.example @@ -0,0 +1,9 @@ +API_BASE_URL=http://api.claycms-dev.io:8080 +HOST=claycms-dev.io +NODE_ENV=development +PORT=3000 +PUBLIC_PATH=/ +SENTRY_AUTH_TOKEN= +SENTRY_DSN= +SENTRY_ORG=keepworks +SENTRY_PROJECT=claycms-development diff --git a/ui/.env.heroku b/ui/.env.heroku new file mode 100644 index 0000000..5168908 --- /dev/null +++ b/ui/.env.heroku @@ -0,0 +1 @@ +# This file is a workaround to make dotenv work on Heroku diff --git a/ui/.eslintignore b/ui/.eslintignore new file mode 100644 index 0000000..5fc937c --- /dev/null +++ b/ui/.eslintignore @@ -0,0 +1,2 @@ +**/*.js +!src/**/*.js diff --git a/ui/.eslintrc b/ui/.eslintrc new file mode 100644 index 0000000..ebf2fce --- /dev/null +++ b/ui/.eslintrc @@ -0,0 +1,36 @@ +{ + "extends": "airbnb", + "env": { + "browser": true + }, + "parser": "babel-eslint", + "plugins": ["react-hooks"], + "rules": { + "array-bracket-spacing": ["error", "always"], + "comma-dangle": ["error", "never"], + "jsx-a11y/anchor-is-valid": ["error", { + "components": ["FooterLink", "Link", "MenuLink", "NavLink", "PageLink", "TextLink"], + "specialLink": ["to"], + "aspects": ["noHref", "invalidHref", "preferButton"] + }], + "jsx-a11y/label-has-for": ["off"], + "no-class-assign": ["off"], + "no-func-assign": ["off"], + "no-multiple-empty-lines": ["error", { "max": 1, "maxBOF": 0, "maxEOF": 0 }], + "no-param-reassign": ["error", { "props": false }], + "no-plusplus": ["error", { "allowForLoopAfterthoughts": true }], + "no-unused-vars": ["error", { "ignoreRestSiblings": true, "argsIgnorePattern": "^_" }], + "object-curly-newline": ["error", { "consistent": true }], + "react/button-has-type":["off"], + "react/forbid-prop-types": ["off"], + "react/jsx-filename-extension": ["error", { "extensions": [".js", ".jsx"] }], + "react/no-unused-prop-types": ["off"], + "react/prop-types": ["off"], + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "semi": ["error", "never"] + }, + "settings": { + "import/resolver": "webpack" + } +} diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..0592763 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,85 @@ +## Node specific changes +node_modules +npm-debug.log +build + +# Git +**.orig + +# OS X +.DS_Store +.DS_Store? +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Environment files +.env +.env.staging +.env.production + +# Thumbnails +._* + +# Files that might appear on external disk +.Spotlight-V100 +.Trashes + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# Windows image file caches +Thumbs.db +ehthumbs.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Compiled source +*.com +*.class +*.dll +*.exe +*.o +*.so + +# Packages +# it's better to unpack these files and commit the raw source +# git has its own built in compression methods +*.7z +*.dmg +*.gz +*.iso +*.jar +*.rar +*.tar +*.zip + +# Logs and databases +*.log +*.sql +*.sqlite + +# Bundle Files +dist/* + +# VSCode settings +.vscode/* diff --git a/ui/LICENSE.md b/ui/LICENSE.md new file mode 100644 index 0000000..e44070b --- /dev/null +++ b/ui/LICENSE.md @@ -0,0 +1 @@ +© Copyright 2018 KeepWorks Technologies Pvt. Ltd. All Rights Reserved. diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..cf14c56 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,38 @@ +Clay CMS +===================== + +### Dependencies + +- [Yarn](https://yarnpkg.com/en/docs/install) +- [ImageOptim](https://imageoptim.com/mac) + +### Installation + +1. Copy and modify the .env file: `cp .env.example .env` +2. Run `yarn install` to install npm dependencies. +3. Edit your hosts file (`subl /etc/hosts`) and add: `127.0.0.1 claycms-dev.io` +4. Run `yarn start` to start the development web server. + +### Profiling + +We use the [Webpack Bundle Analyzer](https://github.com/th0r/webpack-bundle-analyzer) plugin to identify bundle size issues. To use, either: + +1. Use the `PROFILE` environment variable + + ``` + PROFILE=true yarn start + ``` + +2. Use the `profile` npm script + + ``` + yarn run profile + ``` + +Note that this plugin needs to run against the production build to determine 'parsed' and 'gzipped' sizes. + +### List of Screen Resolutions (width x height) + + * 24 inch Desktop - 1920 x 1050 + * 15 inch Laptop - 1400 x 710 + * 13 inch Laptop - 1280 x 650 diff --git a/ui/netlify.toml b/ui/netlify.toml new file mode 100644 index 0000000..0eee231 --- /dev/null +++ b/ui/netlify.toml @@ -0,0 +1,4 @@ +[build.environment] + NODE_VERSION = "10.15.0" + YARN_VERSION = "1.12.3" + NPM_VERSION = "6.4.1" diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..98019dd --- /dev/null +++ b/ui/package.json @@ -0,0 +1,128 @@ +{ + "name": "claycms", + "version": "1.0.0", + "license": "SEE LICENSE IN LICENSE.md", + "private": true, + "description": "Clay CMS", + "scripts": { + "clean": "rimraf dist/", + "lint": "eslint src", + "start": "webpack-dev-server --disable-host-check", + "build": "npm run clean && webpack --progress", + "build:staging": "NODE_ENV=staging npm run build", + "build:production": "NODE_ENV=production npm run build", + "profile": "PROFILE=true npm run build:production", + "postinstall": "if [ $HEROKU ]; then npm run build; fi", + "optimize-images": "images=$(git diff --exit-code --cached --name-only --diff-filter=ACM -- '*.png' '*.jpg' '*.jpeg' '*.gif' '*.svg') ; $(exit $?) || (echo \"$images\" | xargs imageoptim && git add $images)" + }, + "pre-commit": [ + "optimize-images" + ], + "repository": {}, + "main": "server.js", + "dependencies": { + "@sentry/browser": "^4.6.3", + "@sentry/webpack-plugin": "^1.6.2", + "@uppy/core": "^0.30.2", + "@uppy/dashboard": "^0.30.2", + "@uppy/react": "^0.30.2", + "apollo-cache-inmemory": "1.4.3", + "apollo-cache-persist": "^0.1.1", + "apollo-client": "^2.4.13", + "apollo-link": "^1.2.8", + "apollo-link-debounce": "^2.1.0", + "apollo-link-error": "1.1.7", + "apollo-link-state": "^0.4.1", + "apollo-upload-client": "^10.0.0", + "babel-core": "^6.26.0", + "babel-eslint": "^8.2.6", + "babel-loader": "^7.1.5", + "babel-plugin-lodash": "3.3.4", + "babel-plugin-syntax-dynamic-import": "^6.18.0", + "babel-plugin-transform-class-properties": "^6.24.1", + "babel-plugin-transform-object-rest-spread": "^6.26.0", + "babel-preset-es2015": "^6.24.1", + "babel-preset-react": "^6.24.1", + "classnames": "^2.2.6", + "codemirror": "^5.45.0", + "compression-webpack-plugin": "^2.0.0", + "copy-to-clipboard": "^3.0.8", + "css-loader": "^1.0.1", + "draft-js": "^0.10.5", + "draftjs-to-html": "^0.8.4", + "draftjs-utils": "^0.9.4", + "email-addresses": "^3.0.3", + "eslint": "^5.14.1", + "eslint-config-airbnb": "17.1.0", + "eslint-import-resolver-webpack": "0.11.0", + "eslint-loader": "^2.1.2", + "eslint-plugin-import": "2.16.0", + "eslint-plugin-jsx-a11y": "6.2.1", + "eslint-plugin-react": "7.12.4", + "eslint-plugin-react-hooks": "^1.7.0", + "favicons-webpack-plugin": "^0.0.9", + "file-loader": "^3.0.1", + "filesize": "^4.1.2", + "final-form": "^4.11.1", + "final-form-arrays": "^3.0.2", + "final-form-calculate": "^1.3.1", + "graphql": "^14.1.1", + "graphql-anywhere": "^4.1.28", + "graphql-tag": "^2.10.1", + "hoist-non-react-statics": "^3.3.0", + "html-to-draftjs": "^1.4.0", + "html-webpack-plugin": "^3.2.0", + "iframe-resizer": "^3.6.5", + "imageoptim-cli": "2.3.5", + "jump.js": "^1.0.2", + "lodash": "^4.17.11", + "lodash-webpack-plugin": "^0.11.5", + "mini-css-extract-plugin": "^0.5.0", + "moment": "^2.24.0", + "normalize.css": "^8.0.1", + "numbro": "^2.1.2", + "optimize-css-assets-webpack-plugin": "5.0.1", + "path": "^0.12.7", + "pluralize": "^7.0.0", + "pre-commit": "^1.2.2", + "prop-types": "^15.7.2", + "qs": "^6.6.0", + "rc-slider": "^8.6.6", + "react": "16.8.3", + "react-apollo": "^2.4.1", + "react-avatar-editor": "^11.0.6", + "react-click-outside": "https://github.com/tj/react-click-outside#master", + "react-codemirror2": "^5.1.0", + "react-color": "^2.18.0", + "react-dates": "^20.2.5", + "react-dom": "16.8.3", + "react-dropzone": "^10.0.6", + "react-final-form": "^4.0.2", + "react-final-form-arrays": "^2.0.1", + "react-helmet-async": "^0.2.0", + "react-hot-loader": "4.7.1", + "react-iframe-resizer-super": "^0.2.2", + "react-json-view": "^1.19.1", + "react-jss": "8.6.1", + "react-modal": "^3.8.1", + "react-moment-proptypes": "^1.6.0", + "react-popper": "^1.3.3", + "react-resize-aware": "^2.7.2", + "react-router-dom": "4.3.1", + "react-sortablejs": "^2.0.7", + "react-tabs": "^3.0.0", + "react-virtualized-auto-sizer": "^1.0.2", + "recharts": "^1.6.2", + "rimraf": "^2.6.3", + "style-loader": "0.23.1", + "terser-webpack-plugin": "^1.2.3", + "webpack": "4.29.5", + "webpack-bundle-analyzer": "3.0.4", + "webpack-cli": "3.2.3", + "webpack-dev-server": "^3.2.1", + "webpack-dotenv-plugin": "^2.1.0", + "webpack-merge": "^4.2.1", + "webpack-notifier": "^1.7.0", + "yup": "^0.26.10" + } +} diff --git a/ui/src/Root.js b/ui/src/Root.js new file mode 100644 index 0000000..7eda2c2 --- /dev/null +++ b/ui/src/Root.js @@ -0,0 +1,34 @@ +import React from 'react' +import { ApolloProvider } from 'react-apollo' +import { BrowserRouter } from 'react-router-dom' +import { hot } from 'react-hot-loader' +import { ThemeProvider } from 'react-jss' + +import App from 'components/App' +import AppLoader from 'components/AppLoader' +import ClientProvider from 'components/ClientProvider' +import theme from 'styles/theme' + +function Root() { + return ( + + + {({ apolloClient }) => { + if (!apolloClient) { + return + } + + return ( + + + + + + ) + }} + + + ) +} + +export default hot(module)(Root) diff --git a/ui/src/assets/fonts/claycms-icons.woff b/ui/src/assets/fonts/claycms-icons.woff new file mode 100755 index 0000000000000000000000000000000000000000..e62d10323b84f033ecd144d3daea1e02341abc3d GIT binary patch literal 12660 zcmY*0R*4|q${W}v3zNkU;HZvP**6IoiedAa{SWjzW9G@XlM63ELa%1d}-ic5&-sp zfNEjuW&WjoacT_!;5(wq8V04MnUN^~Kx_C_!}J9XjLT?K%P;XuOZ?)5Um%D41`cLv z=j!>T{nz7bT`+p281P$L2a~TlI^r)*`X44{y8P{oJiqev-@odJzCa8(05sYg*_nN5 zUwr`kUv-1;&yq@x4lb?$0D~j|07(M?z%)P)wnuudnHU-xngW6=9rQc`|K^;?q>G$j z092o09svL4Obrcpz>JxR&5aEq`~8W{!R~MYa07tu|C@W8<_Zc*3ksW!V2%q4>LZj6 zFiSMGG%_?aHas*9gaCs~At9b*59-#5)I+QVM;L2yu=ANQq*l8HJ zI0tkqfDB{77ZVC`00aQY@*-w_ZT4%{V4t4=6wkG4Uj$F!Vw!p0sf3%GN7IX%p0(7w za%IOa#;z*=RJ*P*l($4L+?aH`x^FHHw_K*5 zr`t*=z?Pr5x$NUYlo=;o>i#r2GS}qo6VIQdi_U5?v#QcWNP9_?R1FRn(3c$|(r1ek z1Ce|AO5V-iWxsiv`-4vFaFH|J++kE2KQ?pt@^dJW2~YRAaGC?4nm^gPrFPzQ6Jwlp zxl`e|Sqfxps4d2&>vJbf$HnsLvSD@#5Oi+}lh1^M*n2JDbj-}d06;kN*6$A z5Ou%wZdYFl_&0jtmvpb9#kqgqjb~qPsbv;~3s}pO)ct(XWt+i^RPNu}-CNs1dg4A7 zwpP()8VTE!D*Kz}ub4~>DRcqjhlr_%wh^qeXwsf-Ms6zeY+L#oq_YtrpB7yss!{V8 z8!ylmRNSFc;wdoGq_MK`GGEibmR|+5jOtGzdUo&)qJj_mE|i{Qc}pT^B|o5qsw%vwmiMRZ0HT*#my!|gl*R8G(BPx%YKA7Je+k+t zGd=wzINBv_ z1h3~MCRUCGipn!Wqo^wGaI@L39lmL1ZF1W@hyxA^Rm77lXUf#LI!r_tVV%tOUf}}8 zA=UYx)>FbwfnGHgBtI6|UE;|Yb*7to@ocAK_P0jVTH`j-HrzJkHek3JgH`0er^{f4 z@hA?04$NAzHSWB{qG*`GR63xsPSP57rrHUg=eShEQ#F4>ZRBOD-DUQ@(7JI#)tBvD zF3>?F;%s;pXGD)*6#$VJ&U74juCsFN*BabM;+qC|-6KJz9b)&8o$|HgS5n>(TbVn~ zU?_li9|$n+83v^aO5?)67&PU7lyzD#Q^?DCX^f*1l8c1>s- zt|>MNHEW=M<^E0a6Oe`%R+_!912ubj_r| z0tT~<_@I@{yFycclOh#uaJ9s*_uB21w03O>)C_O-UPvH6ee=lW4SQ2lMej^R3fvif z9&js74GS&WK(PjSfoBxzFQq}YYNg4uSI*tz(1}%1|2b51Mmr-LcWu|PoW+hvRJdi- zVxls{L{Cg{F~*x3NLVmG>F^~O|EIA*HKHfx|fFEd1NDFP}uU`f8Fxf zuU5DfJ|^RoU$jMpd;VO81Z;{}g>$~WCS;Gz(cUK_JL-wq(c52fOFId=fz73kS3iM03gvhM%u9E|8 zh%STcI=#UMZi-28?YOoN#xrW-05~C+gf%20^)C)Elmtj126>EyrEa&HC-S_V`4@dVw%=Me_ua^K`vPUmUuKf2;4{pU(KMu}Pp8Zs zM?{ z+HkD!I9uAvUbX=Z{R+o{YH1X$dQHiEy1dBC{97GmXBH%DqMv*R6Yt>eC))r9}zbP#ILvyIaAZ{WZmRU+Ip++ezE*#>lBs}yg&ch`N?%pAI zwC%dw=;7yhypsPf<__Wv2Dx;e{jD3&y*+th`O;?A&|0%~Ro#f}!66YfTl3xpCOw8%CUcQt#>k8sWpU%Hoz;;37eD=d|m1m&}ahQ!RF z$e@^N7f8ODj(u;R&pP3)Bd`rkkM-_9Tctw#qk=%uEZK-;89D4hw`P-XJ)cgJ6!W_j z`eb9FY2u1yJ`B?-K_OIubQWwP?c6q{&cdy<;Td_Nf08#;mLiTFH;h(yNq zP@?xtus&>MkMUN!Leeq6#q|08UR<#Zh~Rbo710s0>L6Lm>Q0ZERUI5?Q5iAkl*vi^ zx~Qkk6=TulK)|8^v1s%-v^g|961+6h8YoQeRrx*!33_$mY*%r_$v6ZrNipv^Mb@+D zag={YAybAPA4hU%ChHT;bjZ3%pnP2Mk_cBGy9xJF2l`N+P2XsbVm{`?vGwLOjjOdL z?T&MnYM+0n$L4Xa*(Y!QPZp`=r1N7=F)C`(kXS~B$bHcj2=weYQ-r+k_C_A3xIn3G zM^Wzq=yZW4k8{oKOHGHXffE7;$=U!ezk#?Dqy+^L%WIZ_(1ms2gB#M=8J$uplhqVs zGi+H1trLk388d;~_k(a-wL<1p%vP=j+LR*ZV1-s)9e|UX{^w~{g73kE1hLazx{mzD zZIybtC6u1Qa&5)CH5b-dba8v9NXE|QJ(xi+(?#LIdUB80+67`exPQLS)k9?hu+95b zZT@WOYO}+Cb0op{1op$M^eQ*Md!ElAzF4{5S?l^a^m-S&Rw29KU_W`9X|mNTD$C2&?J!Rti5nRnGJm2` zu{K3uGUN78Q^$w<0_Am+zu4DDr%HFockQIa%})IW%MAfW+dVcx%Kl&mn1hoGo=e)r z{iA@d`fr+-Bx|4CC@LNom~^;oI+XgF>H9A{N5tjr%inCx*Cla;e$)e42PO1IsJ&pM zjB1L`t&w6_y_JPksiUE@G&xw*I-wz9{pC>A$YBe!MKq9U+HMs>!M_woFmgHL<`EFW zy!E6({Ml9gewv!4IaT<}zs5x@*Q-g{CJhU#U*I@-T-6fiWg9|q_Xd4rj)<0mRLbPB z#o*Yn^4CDwOjDu(Fxj5$_)*>_TssWP^f6nGKPh)db4R?zIsqJ{&Ei!snYvVY?R9L7 z3|x#d&ioPRoDrKIbxG7?bwc!PV(ID6r&@Bn9q3vqLhREvDk|D81_PE5u6yTnV5aK9 zZDOm#^U;B}axt0=hiY2oy*0igTXPE$f_zOz+uca~Is!CVpC!KcQ@$I!1p7 zRCopX6nUGO1W~=WJbV7vWkc{X8u9i(a4w3|f}ix}elB4JP?VqoL=got z4;oB3Pq-JZs+#Uc*^tkbh3=)?bH(}P%SjGZ*sJ~Gj_MEgAm>Q-e)?ll;qWaAyp9(e zE(T0{Lyur!d89^T(i)^dQq~<{PB1nvA=!r%^w-1V(0R8;r|AdOI%{Re;pJlCsr1l+ z=9(|*sZNg0`kK$Woi>oQ`|rz3bAx!WI$dc*gSLBQa+;s*gxcVqeqEEvU_PhNq1n`c z@EtjjZxoyFZ8%m~cwU~xgc`aQdjgMZXlaPJ`9@^EJ<+LKxs@foHSl+a^cg7>oipBE zB+dlBbu#Ne)-iK6xTW+d9Ik=6KCAh_XK$Xatj*R^nvm=e^1L92?2$*AC-Vk4^@72U zSgA)!&8pODyg%mZ{XORQNu~dSt0*`rkt=<#n&C!WmdF^Y)*7tgOZbuxm=#R2L;E9S zyi(0{2K)o|6OJiqp}`=FoV%8qtkp8YBaMRc!Q}2)DSwR5t1znfqzsY|ARrP|tRxT_ z3Bpn!lJzj1Y?+PACnW!gXFeSnEo{k@#lDpRhNJVQz#tg}Vwyz%EC=0iZv-2#oFyAL z^7zO1V&n~%~PkwCC+Q}Y+A_j4k?GD8b8_D z?nYN*^lgi^b|7OpbGr7|sswJxNr14rR9fq>=P=1H7_89W!^@3KYL;8Ux`>_3r#K{6Ut>1kvQ&*t5G00`sUw5yV$8iNsZinv&SfXph} zKQ6GJv93$tq4jUi=ghWBbNsGy&_hqgXI=pcaz=Ke-_1BrmPpapv1lh;uU?+;8Zc%o z!%KgW;AZY<^$ap;kMoiSv-m{K4Z$EsW3Y{)b2->(OLB23I-ZJCCUn54rKVOJi!7wf zaya(e7^%NXr!;OXMD^^aG0cG$G zpPbIn?ifOUB=7l@kJxBPg~a{QjrDibTSke)?b>7zVtcN@>~_}qJxsrI9ct;gHJZ+b>1-&VAcfRNg>!2xqP5&_3O z`^SbH$b@Udb-#y`H%?=0a+tTlS=}5t*qKAn^!evNoR$nh6aL5M`i5_wbnH+JzI)ZR zQ_;h@HF0A2WI1|ea!V~_(e#$vf2YP>jQRN=nce%*mT%6r2ai!@5p_?G^QyLtx}XGo zlISfC%Jr=gu^oTUI8&WaEwbt9$F2u$BFA&nQNj52=(FpygUJQFE7oS{LSew;GQ~;g z^s$V$Agq7`%Mok9`T8mEuFOlP>Rt>**+H`ZpA7F)s1gJ9m%rQd77MTHJby8&&h7j@ zcVon}biuI*G;^)3-5)OWJ|ipwtaKd5Uyb#D=kZkgG0%}S2ms`Ue-+W7G=h= z^rpcj&T3XJ+pW*qX2#HsP}uKhmSAm#8hzMKzx?xA60Gw#G<*+C;;@NKo;xeAatR!p z5X>?NUH#oQm=GCllz)*PmR|jmi@?AD2G$84W0fKUQPF{b?XF)XJmU)nJ;}MnMURu2HRZncL>-B6Meg%aJR9~izB!1qSN9(^@jLsa)@=r zN!8pTY-DmMk9YbT5plCfGx|ly@0FDwUa9z*j;qVz8nAlI2C<m zpPI!ttJtR1838O3LmX&+fuc$FuBxAY%`Zz>N?to zw$mn#w<%%z`~2PTw};U!#uNARA7@yN*8;1ns0AY0=Cb5pbII9PgUaA_nusBKobHED zH9o2JWrZ8G&EZ2fFE#cKS1b4d3LYNQ7yKhJzU8;i1-uANH8mUEQ~ULqETAr=Nrap1 z{>2N78xG5!>SIR_kH=DJD(SSbJgAr+so@X!*W82K4fS86bF;rea8LIkcJOdgz2w8| zd*Xjse1|wRp|DQe$S$WW&^%5=)gv~pE9(Nj!$%Y!)F}D8S|3Zer^lG^eGYza<5T*8 zlAK$qnN=1*e`xY120q3@c=7@E^ING!l;OkHL`*u2mj6E0=-S*X9x#Cak+Bj#ec5Dg^eDZ2=v*Ko*ByClb!XOK(Y_bg+3*0T!4dpqw zz|(X*Jh$Tv-@NPE-aD*<#r*@XD-HwdgY~0-kXQ;~$9*RL8ASP^?5fuJ4`H7Eo{pBc z?-sG`-vwGwdNW16Q{7eS-DiPJ>z&t5ItjphcrIxP?QzV`vh zW*y<-?F~3*NWcuBv?x9@3z}sl=0XD-wIy0&eV}M?z@4Sy!f_Dm**~v=9mZ8UdnyDT zyYUogo7NmcX1Ql9o|7)w-dsbPd61&v+J5W>nvHywMoE$aPF5Vck#4r$n!2Jhx#YMU z8s-Ll7Uw_Lfff#(0h^ksHMpNu-2g%H%b$5x{9&z1SgvkBOvu>anzHt28~b}YiNlBw zMS+(zC9l6n40HDaR^yY`5T8l+Bl($`o_i)IWcxlv8k6^r)4Et@CN>uV*OGd{rUY;` z;Z=b#2Y;{>1UK92c5uLu0Czu|X8Sn!4KHUU)_eXj+>M0(Ga6pospH^X0mZOi^2K-J zD1DDrltqrBBK$R$1zX_NmFu)h48rbySv-ZR=?1ijRZ_walp}1M%$EiCfUgxBskGRMCA)lZ+gSa`Vj0K(J!60YjBZ+$j-!mY7@HsscJ$3_LI45Uh zmK~mgyaYFHi_%;~AD}cytSEjqkT^yT9}Bib8C+G8tv$UwM)sX=(YH3*rAwvG0<_X!|_n9 z3o{nV;=x``l)OS~cO8FALRu9WI!XdlLnf?#-vA*cUN|oISm^3PF*Fwx}yh9o1r za4o#XtWatoIjxNHjrkPm_N2^UKlRC|XZo1vh@uojAR+o0yOqN3jV14+n42I}jJ{VX zQ(?WUfXwcdOe}YQdsjBh35wkEl9by91`~-E^U0J-Ot&3D1KMsp)lJJGNH8Xv@7#T8 zqw_hjw__vW<3#-Ks>h_ec(Z$z%)7W7vMNA2(mVI)g<6d0NzW+3=Ll@&mC7J@)$1R- zWie1MMXya_t?y*R%yQd6!8^Y>D6_$&J$?a(V2fHmSzq<;;Fu!J5~qO}sfCe^@vl|d z8y`h(f;?@ryd|e{0ldNZBhmxtYI4NbIH#=n!cvC3LBDq8AwuRkGnr7gV$L1K$R}y; z$>u3^*`^LN-?IHlzuRG8c&o3k3jNOe?&2Z}RIOIbD?TqqpFScE19QhLc3{vtLhu+( zDx>DzWm&i2TXO&FS1JgEQFV_bApWon3PU>e*sg;F$e4^g(+>^t0s%s1?xx<<(eqDkk>R zCExj6kE({(HLtPQuNm`IvWjhe>-PA>v0dE(-ye<4ga9zK{@|b(v7>^fk zV_rjaYm{dPm#X*xZ3KAGi&$PVtJx{G9W>7PwIeC9x3-9s=3<7e94`=_6f3)B+ytXn zZjiCmE7Y5f#EZg%`1J3Tcyct^g2}?`z!3@-Nxu9D6qI-WyBj%aBxdLAPl`L_-@UwZ z&f65f>Geg%DN>1@TT@f9e6dOHQPH9+{GB>Yo2x3dgbS)Wqd_#{#xzeE_wn~AIige< zxYJh5qzl$ftHT0#YRqvIAjB12t6FMLsBI>)J&Ml&C=yR>wXz|t$URzbidyX{Q6y^O z5VPGWw1l%UlDo-d`V8VW_m*+)du7IQtI>_YLCAcP8o4U%44b<>S(>fpjYAt~kKEbh z@_q$*Ol7IbVfpF_5M}K@Yx~%Tjf=;x$*!>9cyMD_o%^5=52_PBNAF3CAe2SNE2?%x z@cDguWvwDL_ZZ2)bD_dQpY|CNKS3$vRGuQr^hzOr26ZgC@)8n!4BzU5@7vs|jhj4I zJkwQys`f*IIC?@ru)LKu?nd`DR2;3(_V~PloQMwn9+`4s+4>gE8H6-H#Z#Fyeaf8N zy~`EpChGZ@X=Qr8Zq^P4q7sUbJdy2s)9RyK$c_roo^;ali(Db)W2;sSs1~w^@aEf$ zDEV7qT^=~VA*H>*N@*WC%xXNe9P*(smF5gY*K3|n!5^m{J*UQ$S#(&lnXtESkab|5 zs3!`46aMU?sv`scqfZ>rC`M2Xwa#tqK)-(YK2sJLrf4F`DJNco z@1HpXdQM?-#Ce_$_?A>Im;YkueI@0&ogCnGiC)@bc2{&A$P+`eFZsIr3Mm1Ao zy#IrXSs<_-BI1Sh-IqTT!zdBub@nH-Z}2m_LE_Z66NH)t4{YS!))omO�L}l1>>! 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 literal 0 HcmV?d00001 diff --git a/ui/src/assets/fonts/claycms-icons.woff2 b/ui/src/assets/fonts/claycms-icons.woff2 new file mode 100755 index 0000000000000000000000000000000000000000..62e8cd065e652f67f4f717fc68a54614d380a527 GIT binary patch literal 9716 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 CvgCOH literal 0 HcmV?d00001 diff --git a/ui/src/assets/images/external/footer-background-mobile.svg b/ui/src/assets/images/external/footer-background-mobile.svg new file mode 100644 index 0000000..e28b989 --- /dev/null +++ b/ui/src/assets/images/external/footer-background-mobile.svg @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/ui/src/assets/images/external/footer-background.svg b/ui/src/assets/images/external/footer-background.svg new file mode 100644 index 0000000..18cb476 --- /dev/null +++ b/ui/src/assets/images/external/footer-background.svg @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/ui/src/assets/images/external/page-circle.png b/ui/src/assets/images/external/page-circle.png new file mode 100644 index 0000000000000000000000000000000000000000..7d763857b5578451d5c57ca922d060d0a95cbc8d GIT binary patch literal 9602 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 MUoz3I({hggKW$-^{{R30 literal 0 HcmV?d00001 diff --git a/ui/src/assets/images/external/page-circle@2x.png b/ui/src/assets/images/external/page-circle@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..383d4524584cd7db04d65a4fa7fecf418f441ce7 GIT binary patch literal 20565 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 literal 0 HcmV?d00001 diff --git a/ui/src/assets/images/loader.gif b/ui/src/assets/images/loader.gif new file mode 100644 index 0000000000000000000000000000000000000000..6960011e6d38e18f82bc993f77be72df0896e6c5 GIT binary patch literal 16087 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 Bh-Cl( literal 0 HcmV?d00001 diff --git a/ui/src/assets/images/logo-symbol-color@2x.png b/ui/src/assets/images/logo-symbol-color@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..b9ed2a0309db5ef4bc85515351f8b93fedb29a26 GIT binary patch literal 1693 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 iA|Gud#m~_|gLp=d2pdk!L5$Z!c zFI}M?jkQomJ#jX~^U@va0ayfeFclX-GQsnq?%+6nD#csa0+I=y4>ceCp-GyLGa;Ge z`A|QACTR$^g=CWFL%qmhnBN&&K~gLS+hK3ifm9K7aJZo+e5i)n4ztp(D1f9uO3g?b(aL6fi!O;Oiz4G%yKogoqZ05v=;{04!)s7)bH<~M{UVIua1#8?&{atzco z2z#b6)SCDT8p1-H;IM}3SPdl9Rc<&VQf4UADU>52_Vk{q@ zK~rEEPDn$j^)U(>!3Lb>u!hD^!$**i9)cP^g9yJl6&m0vh@(fK0oLLmNQ`CSOQ=N& ze#gBy0f*sOT!{}b7izHr2d6O9d<+QqiuEC`$5}W57vXvIMVYa-J(66*o|X-w;kJ+uQEC`f{;9uZ5?>B-Lp(%jfEmb##MpLN2{klDHeB90n+r8Omx56D!bYf} ztK;1WSw`@!l3c^tP{R<2qvlY<{Sb)p5n(j~;^^u$eLJx&4$t5r>W`p?_Q-)aI>vG; zZ85fp7;0;%VFIc{LTCoXswCHtjmc2MQII4RVwq)*UbB3tq!At4oD5;SNH0i&uH5ngjm@&;*)5 z9Q_1MU^XO4wJ;rOF&KG}G`Iw6XblO?{^0s`=uquz`~ectRgR%ngYAR5E+h$^f}V@S z_!MvRD#TH5s43)N_$6opH$l>%3Dj^ZB!WV`ZFnd`sEwe8DK-VwOdYa9 zd?LEr7V1W50?$`)s9h>3)H#-MlEf_3DVEI>Ns;HALIpZF9%Rb4d|S5))oh_IfhO=y z8ou;*ssvy9*F($c6T?s!1dfHmyFEmxXG0CYLTqq4)X)NA3v~uGf#wiLKW_#PQ)=iL z!%)qQgx(c=)G*63sw&1#5R0#%hJ#@ZbyOP0Q}3o>Jk`Y*!TgZs@zfefFGi znBm>!5Ngn<>NSX=wq>+70Ai@r1OK{^G@EAbq{T4Q@z8P>K~m)PkfBzyOp@j!KZ&jB zTOo#6Y@y~sVyeC6iMo2sLj4_T*bR~bot#1q9#vfpDQB?dFw2$@QydPZJPe7cx1okh zAQ9BT5X0vYLVY9hfe^b|*bpJqJuI`)9uNo)gc=${?EEpmL{4^mf(`pEQL_l4o*MZD zOuHvscBULgRr^EmQG&l`mF&Ukju1!nEBV3c9L%=tqaOhYsUOsEW`t1lun=mv7!uOf zSQb50>!_+J^0CNuTH*34NOR^k?@;d)3SE-&{Lg?zbh6*LLUaU~>1w!~DZp_}7b&&#k8YPdNCGt9bJ z3^h!_K8~*rb%de>FGdX2I;yf9URi>|WJ5g$8ekYCgoi`CE`1>iAu(_|N}z`6xFX;H z^^W)qWl%#GWJL}YxCLsl2G65bz(JY2G0%9gaa)63HLQdt@PX?E6f>X!&dlHi6sz$Q zp2aQLDR!@;0JG5+58+M};T`m_1`bR{{S@yCwFww8cE*Qz1`pvC{9*}d6skuK)f`pX zm0|BVUFlOFvn%Sk?uOal)|J#|m8vP7^HM*CCEKIO$oa`1g)I(4L4X^;)LLxXAU45vVwX;*P z(<#C4iu)hm<3;j2)Nq;qfoO5!*+x@yksB^lyNhG;BPO@UKBxn!RJ`}0hSw_khxQJ_ zwh+%lH7vJ0MC}wl)bQ6Cn&#tlNR{W^2{rr)Nqwlm4vyQOrFa7ckSfa?12tTbzEFV! zu^8%LJkEhsIo>Hy!%XC)H&o#0koVX3z}b*0!}|$pcoCBRP=S-M5*or#6rmo(+idM* z*>Nn!c9{zmI1KZlA#FkjJb@#S1MxN+Z$`ev#3IT^&0uH>Y{VG+gvNLaPoW0Ht90(b zQ-*^pCRCshEud+#6U6h3P(!;AbK)JU=R*am;XM?`F;vfoYT6IKpe&A|dOlRs{`d+d zaSYY-p_+EUQy3lBP(2^2=`cKpu2>hxP(2^2sSqck2rbYaBQO<9vB8Jx0l+^1xV?_h T2iuUB00000NkvXXu0mjf_%2B_ literal 0 HcmV?d00001 diff --git a/ui/src/assets/images/logo-text-white.png b/ui/src/assets/images/logo-text-white.png new file mode 100644 index 0000000000000000000000000000000000000000..7153ec814c7ea8bc7f7c9541052943012ba21da2 GIT binary patch literal 1781 zcmV1im9Kg43iL8Z|6VP@6G(LnKg_c z9umpoO;)py(_8@~Iv+YPjoti*^h6_wOxAG|#S?8H2C)n66HOqJ`HX64gkdn6i+DWI z0V1A{Tq#`R2s_vi4Jcaiod&{RtRR;z#6-*XXwN|#s4n8^)?W{E5q zWZI^eqcOZ+DBZCLgRxPX+%~05EE_aVdL+T1gOtIlrjJr;sWxnjC>VLt)V@*5Jgb0k zfP6A&8CKtEj3Ac@j3hmH7{t<((M%+Vz9jg)PbHmnQVhc|=t?^2q!Z`jcl#t#4a1;V zn&L=>cx%qvK>SX2n14Yh=81`#PdST73ls*O_>gl_T45#KoOReL%ExO*@}iI^Fi7H- z6r?K%GS`Nph_+#Yh#`+_$}LyL4B5X?!(b9u6t%B2#Q|fODEmP>n!Q3I)8GlIx#i0I z#$`!nA5FtVvD~kpA!HD6QD7i;DnjWIG)G!QY zdnrREN)i|780H3f*J|r49`daNVtIf<%kg{P4F*qIauv+7wnkefa0Ix;{c;_q3F&3Z z(Yz;0ryGW0^BqzZ;_D@eyf9JRC${BHCwRv2F95e{ESen#EjTMiQcOEr{Wul?-`Zfb z6kDp2Bu9^NVog=i#xM*@{3d!yxu`Emi@JqFq8X2q<$SM!v9)G7D@vz|(luHb=KuVg z+K%xt)m*pLStROAbx4mCC98Zt&-hLCCq?6(rFF4Z@lXE&z*zkOYzRJ-t;q*n>9l9 zt3XS1Rx3aldHSHi14b+Hx1Tb7Y%BEaYad>CrgbP;fdy=A7k)&1h%n4JVvT$YY{`XRKhURP#?MU(Z{Jkg<|ztqV0xZ800Fn zAajCt$HO>DVVr2?Ev>{VA<<6>BT-zNDbuT9=Br^?2DX}Rh9Qp2u1G67q(ChpWEgFQ zcUZ3RACttUyl76=;dGg2e!1Gzz-29rEK#z6uG|J-slEH)L-;M;?v)nDGS{$aiFszD zb;qBvjy+XzR07{qC4f3%G!X{2f_b8q47-S&+!-EFa_2Ztg$N@>C@xqit5M?lz*5sG zS{O|@Af!&3TglJz?FLrrvtyQl^(}xBue9H0TwHr4l00sZ(<+161xl+vdCdMmHgl=^glD~ul2 zx?HRt+Q9lhL?VXatmdTE$|*i)lrylWm@>-b088kOY`(U1StUhGGIfxyjwt;_)VVvb z3J}X&Yx0h9F8HVX+cwK(sX@@pg z7FpMrh@n)t4|jPBNB9LdD`~hp5MlMz$1xED!%Rku4b)H_R(KNIBH>c36{-FmaB4|& zUAmgh4A&p)HwYN>12GKF{DuA^{c^_Ar9q--MuqrWy9Za%>l2Hs9HEHy4GKnUhA@O7 zfuyaSm2( literal 0 HcmV?d00001 diff --git a/ui/src/assets/images/logo-text-white@2x.png b/ui/src/assets/images/logo-text-white@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..8bd5314a1f16d7c63bafc6dc66f6f89270bef363 GIT binary patch literal 3908 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 iOwxw)D1TRJdnYsGf-AN$tgipQ0RX}bX { + const { session: { token } = {} } = cache.readQuery({ query: GET_TOKEN }) + const headers = {} + + if (token) { + headers['X-Token'] = token + } + + operation.setContext({ headers }) + + return forward(operation) +}) + +export default authLink diff --git a/ui/src/client/cache.js b/ui/src/client/cache.js new file mode 100644 index 0000000..722673f --- /dev/null +++ b/ui/src/client/cache.js @@ -0,0 +1,5 @@ +import { InMemoryCache } from 'apollo-cache-inmemory' + +const cache = new InMemoryCache() + +export default cache diff --git a/ui/src/client/debounceLink.js b/ui/src/client/debounceLink.js new file mode 100644 index 0000000..a54930c --- /dev/null +++ b/ui/src/client/debounceLink.js @@ -0,0 +1,5 @@ +import DebounceLink from 'apollo-link-debounce' + +const DEBOUNCE_TIMEOUT = 250 + +export default new DebounceLink(DEBOUNCE_TIMEOUT) diff --git a/ui/src/client/errorLink.js b/ui/src/client/errorLink.js new file mode 100644 index 0000000..19ea8e6 --- /dev/null +++ b/ui/src/client/errorLink.js @@ -0,0 +1,11 @@ +import { onError } from 'apollo-link-error' + +import { logout } from 'client/methods' + +const errorLink = onError(({ networkError }) => { + if (networkError && networkError.statusCode === 401) { + logout() + } +}) + +export default errorLink diff --git a/ui/src/client/httpLink.js b/ui/src/client/httpLink.js new file mode 100644 index 0000000..9498f57 --- /dev/null +++ b/ui/src/client/httpLink.js @@ -0,0 +1,7 @@ +import { createUploadLink } from 'apollo-upload-client' + +const httpLink = createUploadLink({ + uri: `${process.env.API_BASE_URL}/graphql` +}) + +export default httpLink diff --git a/ui/src/client/index.js b/ui/src/client/index.js new file mode 100644 index 0000000..90bc77c --- /dev/null +++ b/ui/src/client/index.js @@ -0,0 +1,31 @@ +import ApolloClient from 'apollo-client' +import { from } from 'apollo-link' + +import authLink from './authLink' +import cache from './cache' +import debounceLink from './debounceLink' +import errorLink from './errorLink' +import httpLink from './httpLink' +import stateLink from './stateLink' + +const client = new ApolloClient({ + link: from([ + authLink, + errorLink, + stateLink, + debounceLink, + httpLink + ]), + cache +}) + +/* + https://www.apollographql.com/docs/link/links/state.html#defaults + https://www.apollographql.com/docs/react/advanced/caching.html#reset-store + + The cache is not reset back to defaults set in `stateLink` on client.resetStore(). + Therefore, we need to rehydrate cache with defaults using `onResetStore()`. +*/ +client.onResetStore(stateLink.writeDefaults) + +export default client diff --git a/ui/src/client/methods.js b/ui/src/client/methods.js new file mode 100644 index 0000000..887ed28 --- /dev/null +++ b/ui/src/client/methods.js @@ -0,0 +1,16 @@ +import client from 'client' + +import SET_TOKEN from 'mutations/session' +import { ALERT_FAILURE, ALERT_SUCCESS } from 'mutations/alert' + +const logout = () => ( + client.mutate({ mutation: SET_TOKEN, variables: { token: null } }).then(() => ( + client.resetStore() + )) +) + +const showAlertFailure = alert => client.mutate({ mutation: ALERT_FAILURE, variables: { alert } }) + +const showAlertSuccess = alert => client.mutate({ mutation: ALERT_SUCCESS, variables: { alert } }) + +export { logout, showAlertFailure, showAlertSuccess } diff --git a/ui/src/client/stateLink.js b/ui/src/client/stateLink.js new file mode 100644 index 0000000..7f91614 --- /dev/null +++ b/ui/src/client/stateLink.js @@ -0,0 +1,11 @@ +import { withClientState } from 'apollo-link-state' + +import cache from './cache' +import resolvers from '../resolvers' + +const stateLink = withClientState({ + cache, + ...resolvers +}) + +export default stateLink diff --git a/ui/src/components/ActionList.js b/ui/src/components/ActionList.js new file mode 100644 index 0000000..670a261 --- /dev/null +++ b/ui/src/components/ActionList.js @@ -0,0 +1,63 @@ +import injectSheet from 'react-jss' +import PropTypes from 'prop-types' +import React from 'react' + +import * as mixins from 'styles/mixins' +import FontIcon from 'components/FontIcon' +import ItemBar from 'components/ItemBar' + +function ActionList({ actions, record, classes }) { + return ( + + {actions.map(({ icon, onClick }) => ( +
{ + e.stopPropagation() + onClick(record, e) + }} + onKeyPress={(e) => { + e.stopPropagation() + onClick(record, e) + }} + > + +
+ ))} +
+ ) +} + +ActionList.propTypes = { + actions: PropTypes.arrayOf(PropTypes.shape({ + icon: PropTypes.string.isRequired, + onClick: PropTypes.func + })), + record: PropTypes.object +} + +ActionList.defaultProps = { + actions: [], + record: null +} + +export default injectSheet(({ colors, units }) => ({ + action: { + ...mixins.transitionSimple(), + + display: 'flex', + color: colors.text_pale, + cursor: 'pointer', + + '&:hover': { + color: colors.text_dark + }, + + '&:not(:last-child)': { + marginRight: units.assetBoxActionMarginRight + } + } +}))(ActionList) diff --git a/ui/src/components/AlertBox.js b/ui/src/components/AlertBox.js new file mode 100644 index 0000000..a4204ff --- /dev/null +++ b/ui/src/components/AlertBox.js @@ -0,0 +1,173 @@ +import classNames from 'classnames' +import injectSheet from 'react-jss' +import PropTypes from 'prop-types' +import React from 'react' + +import * as mixins from 'styles/mixins' +import CloseButton from 'components/buttons/CloseButton' +import FontIcon from 'components/FontIcon' +import { withClientMutation, withClientQuery } from 'lib/data' + +import GET_ALERT from 'queries/alert' +import { CLOSE_ALERT } from 'mutations/alert' + +const defaults = { + success: { + icon: 'alert-success', + title: 'Well done!' + }, + failure: { + icon: 'alert-failure', + title: 'Yikes!' + } +} + +const alertIconSize = 'medium' +const closeTimeoutInMS = 5000 + +let closeTimer = null + +function AlertBox({ + alert: { + isOpen, icon, title, message, variant + }, + closeAlert, + classes +}) { + if (isOpen) { + if (closeTimer) { + clearTimeout(closeTimer) + } + + closeTimer = setTimeout(closeAlert, closeTimeoutInMS) + } + + const handleClose = () => { + if (closeTimer) { + clearTimeout(closeTimer) + } + + closeAlert() + } + + return ( +
+
+
+
+
+ +
+
+ {title || defaults[variant].title} +
+ +
+ {message && ( +
+ {message || defaults[variant].message} +
+ )} +
+
+ ) +} + +AlertBox.propTypes = { + alert: PropTypes.shape({ + isOpen: PropTypes.bool, + variant: PropTypes.oneOf(Object.keys(defaults)).isRequired + }) +} + +AlertBox.defaultProps = { + alert: { + isOpen: false + } +} + +AlertBox = injectSheet(({ + colors, shadows, typography, units, zIndexes +}) => ({ + container: { + position: 'fixed', + right: 0, + bottom: 0, + left: 0, + zIndex: zIndexes.alert + }, + alert: { + ...mixins.responsiveProperty('width', units.alertWidth), + ...mixins.transitionSimple(), + + backgroundColor: colors.alertBackground, + borderRadius: units.alertBorderRadius, + boxShadow: shadows.alert, + opacity: 0, + paddingTop: units.alertVerticalPadding, + paddingRight: units.alertHorizontalPadding, + paddingBottom: units.alertVerticalPadding, + paddingLeft: units.alertHorizontalPadding, + pointerEvents: 'none', + position: 'absolute', + right: units.alertPositionRight, + bottom: 0 + }, + alert_open: { + bottom: units.alertPositionBottom, + opacity: 1, + pointerEvents: 'auto' + }, + dismiss: { + cursor: 'pointer', + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0 + }, + header: { + display: 'flex', + alignItems: 'center', + paddingBottom: units.alertHeaderPaddingBottom + }, + icon: { + fontSize: 0, /* clear line-height that causes extra spacing */ + marginRight: units.alertIconMargin + }, + icon_success: { + color: colors.alert_success + }, + icon_failure: { + color: colors.alert_failure + }, + title: { + ...typography.semiboldMedium, + + flex: '1 0 auto' + }, + title_success: { + color: colors.alert_success + }, + title_failure: { + color: colors.alert_failure + }, + message: { + ...typography.regularSquished, + + color: colors.alertText + } +}))(AlertBox) + +AlertBox = withClientMutation(CLOSE_ALERT)(AlertBox) + +AlertBox = withClientQuery(GET_ALERT)(AlertBox) + +export default AlertBox diff --git a/ui/src/components/App.js b/ui/src/components/App.js new file mode 100644 index 0000000..a8c353f --- /dev/null +++ b/ui/src/components/App.js @@ -0,0 +1,106 @@ +import gql from 'graphql-tag' +import Helmet, { HelmetProvider } from 'react-helmet-async' +import injectSheet from 'react-jss' +import React, { Component } from 'react' +import { withRouter } from 'react-router-dom' + +import * as mixins from 'styles/mixins' +import * as Sentry from '@sentry/browser' +import AppContext from 'components/AppContext' +import AppLoader from 'components/AppLoader' +import ExternalRouter from 'components/routers/ExternalRouter' +import User from 'models/User' +import { withClientQuery, withQuery } from 'lib/data' + +import GET_REFERRER from 'queries/referrer' +import GET_TOKEN from 'queries/session' + +const isUserLoggedIn = session => Boolean(session && session.token) + +class App extends Component { + configureSentry = () => { + const { currentUser } = this.props + + let user = {} + + if (currentUser) { + user = { + id: user.id, + email: user.email, + name: User.fullName(user) + } + } + + Sentry.configureScope((scope) => { + scope.setUser(user) + }) + } + + render() { + const { loading, currentUser, session, referrer } = this.props + + // Logout issue: After session changes, 'loading' continues to be set to true + // even though the query is skipped. The issue was raised, but the fix seems to + // be only for the component. + // https://github.com/apollographql/react-apollo/issues/1869 + if (isUserLoggedIn(session) && loading) { + return + } + + this.configureSentry() + + return ( + + + + + + + + + + ) + } +} + +App = injectSheet(() => { + const globalStyles = Object.keys(mixins.breakpoints).reduce((styles, breakpoint) => { + styles[`.hidden-${breakpoint}`] = mixins.responsiveProperties({ + display: { [breakpoint]: 'none' } + }) + + styles[`.visible-${breakpoint}`] = mixins.responsiveProperties({ + display: { [breakpoint]: 'block' } + }) + + return styles + }, {}) + + return { + '@global': globalStyles + } +})(App) + +App = withQuery(gql` + query AppQuery { + currentUser { + id + email + firstName + lastName + profilePictureThumbnail + profilePictureNormal + } + } +`, { + skip: ({ session }) => !isUserLoggedIn(session) +})(App) + +App = withClientQuery(GET_TOKEN)(App) + +App = withClientQuery(GET_REFERRER)(App) + +export default withRouter(App) diff --git a/ui/src/components/AppContext.js b/ui/src/components/AppContext.js new file mode 100644 index 0000000..f54c8c4 --- /dev/null +++ b/ui/src/components/AppContext.js @@ -0,0 +1,3 @@ +import React from 'react' + +export default React.createContext() diff --git a/ui/src/components/AppLoader.js b/ui/src/components/AppLoader.js new file mode 100644 index 0000000..580cbd2 --- /dev/null +++ b/ui/src/components/AppLoader.js @@ -0,0 +1,27 @@ +import injectSheet from 'react-jss' +import React from 'react' + +import loaderImage from 'images/loader.gif' + +function AppLoader({ classes }) { + return ( +
+ Loading... +
+ ) +} + +export default injectSheet(({ colors, zIndexes }) => ({ + appLoader: { + backgroundColor: colors.emptyWrapperBackground, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + position: 'fixed', + top: 0, + right: 0, + bottom: 0, + left: 0, + zIndex: zIndexes.appLoader + } +}))(AppLoader) diff --git a/ui/src/components/BaseModal.js b/ui/src/components/BaseModal.js new file mode 100644 index 0000000..43642f4 --- /dev/null +++ b/ui/src/components/BaseModal.js @@ -0,0 +1,78 @@ +import classNames from 'classnames' +import injectSheet from 'react-jss' +import Modal from 'react-modal' +import PropTypes from 'prop-types' +import React from 'react' + +import * as mixins from 'styles/mixins' + +const closeTransitionDuration = 300 + +function BaseModal({ + overlayBaseClassName, + overlayAfterOpenClassName, + overlayBeforeCloseClassName, + contentBaseClassName, + contentAfterOpenClassName, + contentBeforeCloseClassName, + classes, + children, + ...other +}) { + return ( + + {children} + + ) +} + +BaseModal.propTypes = { + contentLabel: PropTypes.string.isRequired, // For screenreaders + onRequestClose: PropTypes.func.isRequired +} + +export default injectSheet(({ colors, zIndexes }) => ({ + body: { + overflow: 'hidden' + }, + overlay: { + ...mixins.transitionFluid(), + + backgroundColor: colors.baseModalOverlayBackground, + opacity: 0, + position: 'fixed', + top: 0, + right: 0, + bottom: 0, + left: 0, + zIndex: zIndexes.modal + }, + overlayAfterOpen: { + backdropFilter: 'blur(5px)', + opacity: 1 + }, + overlayBeforeClose: { + backdropFilter: 'blur(0)', + opacity: 0 + }, + content: { + ...mixins.transitionFluid(), + + backgroundColor: colors.baseModalBackground + } +}))(BaseModal) diff --git a/ui/src/components/BaseSlider.js b/ui/src/components/BaseSlider.js new file mode 100644 index 0000000..396de21 --- /dev/null +++ b/ui/src/components/BaseSlider.js @@ -0,0 +1,67 @@ +import injectSheet from 'react-jss' +import PropTypes from 'prop-types' +import React from 'react' +import Slider from 'rc-slider' + +import * as mixins from 'styles/mixins' + +const barHeight = 2 +const knobSize = 20 + +function BaseSlider({ handleStyle, trackStyle, railStyle, theme, classes, ...other }) { + const baseHandleStyle = { + ...mixins.size(knobSize), + + backgroundColor: theme.colors.baseSliderHandleBackground, + borderColor: theme.colors.baseSliderHandleBorder, + borderStyle: 'solid', + borderWidth: 1, + boxShadow: theme.shadows.baseSliderHandle, + marginLeft: 0, + marginTop: 0, + transform: 'translateX(-50%)' + } + + const baseTrackStyle = { + backgroundColor: theme.colors.baseSliderTrackBackground, + borderRadius: 0, + height: barHeight + } + + const baseRailStyle = { + backgroundColor: theme.colors.baseSliderRailBackground, + borderRadius: 0, + height: barHeight + } + + return ( + + ) +} + +BaseSlider.propTypes = { + handleStyle: PropTypes.object, + trackStyle: PropTypes.object, + railStyle: PropTypes.object +} + +BaseSlider.defaultProps = { + handleStyle: null, + trackStyle: null, + railStyle: null +} + +export default injectSheet(() => ({ + slider: { + alignItems: 'center', + cursor: 'pointer', + display: 'flex' + } +}))(BaseSlider) diff --git a/ui/src/components/ClientProvider.js b/ui/src/components/ClientProvider.js new file mode 100644 index 0000000..f12da14 --- /dev/null +++ b/ui/src/components/ClientProvider.js @@ -0,0 +1,52 @@ +import { Component } from 'react' +import { CachePersistor } from 'apollo-cache-persist' + +import cache from 'client/cache' +import client from 'client' + +const SCHEMA_VERSION = '1' +const SCHEMA_VERSION_KEY = 'apollo-schema-version' + +const storage = window.localStorage + +class ClientProvider extends Component { + constructor() { + super() + + this.state = { + apolloClient: null + } + } + + componentDidMount() { + const persistor = new CachePersistor({ + cache, + storage + }) + + const currentSchemaVersion = this.getSchemaVersion() + + if (currentSchemaVersion === SCHEMA_VERSION) { + persistor.restore().then(this.setClient) + } else { + persistor.purge().then(() => { + this.setSchemaVersion() + this.setClient() + }) + } + } + + setSchemaVersion = () => storage.setItem(SCHEMA_VERSION_KEY, SCHEMA_VERSION) + + getSchemaVersion = () => storage.getItem(SCHEMA_VERSION_KEY) + + setClient = () => this.setState({ apolloClient: client }) + + render() { + const { children } = this.props + + return children(this.state) + } +} + +export default ClientProvider diff --git a/ui/src/components/Container.js b/ui/src/components/Container.js new file mode 100644 index 0000000..d234bc2 --- /dev/null +++ b/ui/src/components/Container.js @@ -0,0 +1,20 @@ +import injectSheet from 'react-jss' +import React from 'react' + +function Container({ classes, children }) { + return ( +
+ {children} +
+ ) +} + +export default injectSheet(({ units }) => ({ + container: { + marginLeft: 'auto', + marginRight: 'auto', + paddingLeft: units.containerHorizontalPadding, + paddingRight: units.containerHorizontalPadding, + width: units.containerWidth + (2 * units.containerHorizontalPadding) + } +}))(Container) diff --git a/ui/src/components/FieldError.js b/ui/src/components/FieldError.js new file mode 100644 index 0000000..941ea16 --- /dev/null +++ b/ui/src/components/FieldError.js @@ -0,0 +1,63 @@ +import injectSheet from 'react-jss' +import PropTypes from 'prop-types' +import React from 'react' + +import * as mixins from 'styles/mixins' +import { Paragraph } from 'components/typography' + +function FieldError({ error, classes }) { + if (!error) { + return null + } + + return ( +
+ + {error} + +
+ ) +} + +FieldError.propTypes = { + active: PropTypes.bool, + error: PropTypes.oneOfType([ PropTypes.string, PropTypes.bool ]), + stretched: PropTypes.bool +} + +FieldError.defaultProps = { + active: false, + error: null, + stretched: false +} + +export default injectSheet(({ colors, units }) => ({ + fieldError: { + paddingTop: units.fieldErrorVerticalPadding, + paddingRight: units.fieldErrorHorizontalPadding, + paddingBottom: units.fieldErrorVerticalPadding, + paddingLeft: units.fieldErrorHorizontalPadding, + position: 'absolute', + top: '100%', + right: 0, + left: 0, + + '&::before': { + ...mixins.transitionSimple(), + + backgroundColor: colors.fieldErrorBackground, + content: "' '", + marginRight: ({ active, stretched }) => ( + (active && stretched) ? units.inputBorderMarginHorizontal_focus : 0 + ), + marginLeft: ({ active, stretched }) => ( + (active && stretched) ? units.inputBorderMarginHorizontal_focus : 0 + ), + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0 + } + } +}))(FieldError) diff --git a/ui/src/components/FieldHint.js b/ui/src/components/FieldHint.js new file mode 100644 index 0000000..5b9d768 --- /dev/null +++ b/ui/src/components/FieldHint.js @@ -0,0 +1,45 @@ +import injectSheet from 'react-jss' +import PropTypes from 'prop-types' +import React from 'react' + +import FontIcon from 'components/FontIcon' + +function FieldHint({ active, hint, classes }) { + if (!hint || !active) { + return null + } + + return ( +
+ + {hint} +
+ ) +} + +FieldHint.propTypes = { + active: PropTypes.bool, + hint: PropTypes.string +} + +FieldHint.defaultProps = { + active: false, + hint: null +} + +export default injectSheet(({ colors, typography, units }) => ({ + fieldHint: { + ...typography.lightSmall, + + color: colors.text_pale, + paddingLeft: units.fieldHintPaddingLeft, + position: 'absolute', + top: `calc(100% + ${units.fieldHintTop}px)`, + + '& .icon': { + lineHeight: `${typography.lightSmall.lineHeight * typography.lightSmall.fontSize}px`, + position: 'absolute', + left: 0 + } + } +}))(FieldHint) diff --git a/ui/src/components/FontIcon.js b/ui/src/components/FontIcon.js new file mode 100644 index 0000000..e403f69 --- /dev/null +++ b/ui/src/components/FontIcon.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types' +import React from 'react' + +const FontIcon = React.forwardRef(({ name, size, ...other }, ref) => ( + +)) + +FontIcon.sizes = { + nano: 6, + micro: 8, + milli: 10, + tiny: 12, + small: 16, + medium: 24, + large: 32, + extraLarge: 40 +} + +FontIcon.propTypes = { + name: PropTypes.string.isRequired, + size: PropTypes.oneOf(Object.keys(FontIcon.sizes)) +} + +FontIcon.defaultProps = { + size: 'medium' +} + +export default FontIcon diff --git a/ui/src/components/ItemBar.js b/ui/src/components/ItemBar.js new file mode 100644 index 0000000..1f4dfc8 --- /dev/null +++ b/ui/src/components/ItemBar.js @@ -0,0 +1,48 @@ +import injectSheet from 'react-jss' +import PropTypes from 'prop-types' +import React from 'react' + +const gutterSizes = { + tiny: 5, + small: 10, + medium: 15, + large: 20 +} + +function ItemBar({ classes, children }) { + return ( +
+ {children} +
+ ) +} + +ItemBar.propTypes = { + children: PropTypes.node.isRequired, + gutter: PropTypes.oneOf(Object.keys(gutterSizes)), + justifyContent: PropTypes.string, + reversed: PropTypes.bool +} + +ItemBar.defaultProps = { + gutter: 'tiny', + justifyContent: 'space-between', + reversed: false +} + +export default injectSheet(() => ({ + itemBar: { + alignItems: 'center', + display: 'flex', + flexDirection: ({ reversed }) => (reversed ? 'row-reverse' : 'row'), + justifyContent: ({ justifyContent }) => justifyContent, + + '& > *': { + marginRight: ({ gutter }) => gutterSizes[gutter] + }, + + '& > *:last-child': { + marginRight: 0 + } + } +}))(ItemBar) diff --git a/ui/src/components/LoaderView.js b/ui/src/components/LoaderView.js new file mode 100644 index 0000000..7a4d3c0 --- /dev/null +++ b/ui/src/components/LoaderView.js @@ -0,0 +1,40 @@ +import classNames from 'classnames' +import injectSheet from 'react-jss' +import PropTypes from 'prop-types' +import React from 'react' + +import loaderImage from 'images/loader.gif' + +function LoaderView({ overlay, classes }) { + return ( +
+ Loading... +
+ ) +} + +LoaderView.propTypes = { + overlay: PropTypes.bool +} + +LoaderView.defaultProps = { + overlay: false +} + +export default injectSheet(() => ({ + loaderView: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: 150 + }, + loaderView_overlay: { + background: 'rgba(255, 255, 255, 0.8)', + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + zIndex: 1 + } +}))(LoaderView) diff --git a/ui/src/components/Logo.js b/ui/src/components/Logo.js new file mode 100644 index 0000000..c21d259 --- /dev/null +++ b/ui/src/components/Logo.js @@ -0,0 +1,34 @@ +import injectSheet from 'react-jss' +import PropTypes from 'prop-types' +import React from 'react' + +import * as mixins from 'styles/mixins' +import resolveImage from 'lib/resolveImage' + +const sizes = { + symbol_normal: [ 35, 54 ], + text_normal: [ 89, 40 ], + text_large: [ 134, 60 ] +} + +function Logo({ type, variant, classes }) { + return Clay CMS +} + +Logo.propTypes = { + size: PropTypes.oneOf([ 'normal', 'large' ]), + type: PropTypes.oneOf([ 'symbol', 'text' ]), + variant: PropTypes.oneOf([ 'color', 'white' ]) +} + +Logo.defaultProps = { + size: 'normal', + type: 'text', + variant: 'white' +} + +export default injectSheet(() => ({ + logo: ({ size, type }) => ({ + ...mixins.size(...sizes[`${type}_${size}`]) + }) +}))(Logo) diff --git a/ui/src/components/ScrollToTop.js b/ui/src/components/ScrollToTop.js new file mode 100644 index 0000000..daa03fb --- /dev/null +++ b/ui/src/components/ScrollToTop.js @@ -0,0 +1,26 @@ +import { Component } from 'react' +import { withRouter } from 'react-router-dom' + +class ScrollToTop extends Component { + // For initial page load + componentDidMount() { + window.scrollTo(0, 0) + } + + // For route transitions + componentDidUpdate({ location: prevLocation }) { + const { location } = this.props + + if (location !== prevLocation) { + window.scrollTo(0, 0) + } + } + + render() { + const { children } = this.props + + return children + } +} + +export default withRouter(ScrollToTop) diff --git a/ui/src/components/Spacer.js b/ui/src/components/Spacer.js new file mode 100644 index 0000000..7c4b0a5 --- /dev/null +++ b/ui/src/components/Spacer.js @@ -0,0 +1,18 @@ +import PropTypes from 'prop-types' +import React from 'react' + +function Spacer({ height, width }) { + return
+} + +Spacer.propTypes = { + height: PropTypes.oneOfType([ PropTypes.string, PropTypes.number ]), + width: PropTypes.oneOfType([ PropTypes.string, PropTypes.number ]) +} + +Spacer.defaultProps = { + height: 0, + width: 0 +} + +export default Spacer diff --git a/ui/src/components/buttons/CloseButton.js b/ui/src/components/buttons/CloseButton.js new file mode 100644 index 0000000..256a7c3 --- /dev/null +++ b/ui/src/components/buttons/CloseButton.js @@ -0,0 +1,25 @@ +import injectSheet from 'react-jss' +import PropTypes from 'prop-types' +import React from 'react' + +import FontIcon from 'components/FontIcon' + +function CloseButton({ onClick, classes }) { + return ( +
+ +
+ ) +} + +CloseButton.propTypes = { + onClick: PropTypes.func.isRequired +} + +export default injectSheet(({ colors }) => ({ + close: { + color: colors.closeIcon, + cursor: 'pointer', + fontSize: 0 /* clear line-height that causes extra spacing */ + } +}))(CloseButton) diff --git a/ui/src/components/buttons/DragButton.js b/ui/src/components/buttons/DragButton.js new file mode 100644 index 0000000..e25d9ff --- /dev/null +++ b/ui/src/components/buttons/DragButton.js @@ -0,0 +1,20 @@ +import classNames from 'classnames' +import injectSheet from 'react-jss' +import React from 'react' + +import FontIcon from 'components/FontIcon' + +function DragButton({ classes }) { + return ( +
+ +
+ ) +} + +export default injectSheet(({ colors }) => ({ + dragHandle: { + color: colors.text_pale, + fontSize: 0 /* clear line-height that causes extra spacing */ + } +}))(DragButton) diff --git a/ui/src/components/buttons/FilledButton.js b/ui/src/components/buttons/FilledButton.js new file mode 100644 index 0000000..7fff4c9 --- /dev/null +++ b/ui/src/components/buttons/FilledButton.js @@ -0,0 +1,161 @@ +import classNames from 'classnames' +import injectSheet from 'react-jss' +import PropTypes from 'prop-types' +import React from 'react' + +import * as mixins from 'styles/mixins' +import cleanProps from 'lib/cleanProps' + +function FilledButton({ isActive, disabled, label, size, variant, classes, ...other }) { + return ( + + ) +} + +FilledButton.propTypes = { + isActive: PropTypes.bool, + disabled: PropTypes.bool, + label: PropTypes.string.isRequired, + size: PropTypes.oneOf([ 'tiny', 'small', 'normal', 'large' ]), + type: PropTypes.oneOf([ 'submit', 'button' ]), + variant: PropTypes.oneOf([ 'clear', 'color', 'flat' ]) +} + +FilledButton.defaultProps = { + isActive: false, + disabled: false, + size: 'normal', + type: 'submit', + variant: 'color' +} + +export default injectSheet(({ colors, gradients, shadows, typography, units }) => ({ + button: { + ...mixins.transitionSimple(), + + borderRadius: units.buttonBorderRadius, + cursor: 'pointer', + paddingTop: 0, + paddingBottom: 0, + whiteSpace: 'nowrap', + + '&[disabled]': { + pointerEvents: 'none' + }, + + '&:not(:last-child)': { + marginRight: units.buttonMarginRight + } + }, + button_tiny: { + ...typography.regularSmallSpaced, + + height: units.buttonHeight_tiny, + minWidth: units.buttonMinWidth_tiny, + paddingRight: units.buttonHorizontalPadding_tiny, + paddingLeft: units.buttonHorizontalPadding_tiny + }, + button_small: { + ...typography.bold, + + height: units.buttonHeight_small, + minWidth: units.buttonMinWidth_small, + paddingRight: units.buttonHorizontalPadding_normal, + paddingLeft: units.buttonHorizontalPadding_normal + }, + button_normal: { + ...typography.bold, + + height: units.buttonHeight_normal, + minWidth: units.buttonMinWidth_normal, + paddingRight: units.buttonHorizontalPadding_normal, + paddingLeft: units.buttonHorizontalPadding_normal + }, + button_large: { + ...typography.bold, + + height: units.buttonHeight_large, + minWidth: units.buttonMinWidth_large, + paddingRight: units.buttonHorizontalPadding_normal, + paddingLeft: units.buttonHorizontalPadding_normal + }, + button_clear: { + ...typography.regularSmallSpaced, + + background: 'none', + border: 'none', + color: colors.text_pale + }, + button_color: { + backgroundImage: gradients.button, + borderWidth: 0, + boxShadow: ({ size }) => size !== 'tiny' && shadows.button_color, + color: colors.text_light, + position: 'relative', + textTransform: 'uppercase', + zIndex: 0, + + '&[disabled]': { + boxShadow: 'none', + opacity: 0.3 + }, + + '&::after': { + ...mixins.transitionSimple(), + + backgroundImage: gradients.button_colorHover, + borderRadius: units.buttonBorderRadius, + content: '" "', + opacity: 0, + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + zIndex: -1 + }, + + '&:hover, &:focus': { + boxShadow: shadows.button_hover, + + '&::after': { + opacity: 1 + } + } + }, + button_flat: { + ...typography.mediumSquished, + + backgroundColor: colors.buttonBackground_flat, + borderColor: colors.buttonBorder_flat, + borderStyle: 'solid', + borderWidth: 1, + color: colors.text_dark, + + '&[disabled]': { + color: colors.text_darkDisabled + }, + + '&:hover, &:focus': { + backgroundColor: colors.button_flatHover, + borderColor: 'transparent', + boxShadow: shadows.button_flatHover + } + }, + button_flatActive: { + backgroundColor: colors.button_flatHover, + borderColor: 'transparent', + boxShadow: shadows.button_flatHover + } +}))(FilledButton) diff --git a/ui/src/components/decorators/withUniqueId.js b/ui/src/components/decorators/withUniqueId.js new file mode 100644 index 0000000..ee22327 --- /dev/null +++ b/ui/src/components/decorators/withUniqueId.js @@ -0,0 +1,38 @@ +import hoistNonReactStatics from 'hoist-non-react-statics' +import React, { Component } from 'react' + +let nextId = 0 + +function withUniqueId() { + return (WrappedComponent) => { + class EnhancedComponent extends Component { + constructor(props) { + super(props) + + this.cache = {} + } + + uniqueId = (keyStr) => { + if (!Object.prototype.hasOwnProperty.call(this.cache, keyStr)) { + nextId += 1 + this.cache[keyStr] = `uid-${keyStr}-${nextId}` + } + return this.cache[keyStr] + } + + render() { + const wrappedComponentProps = Object.assign({}, this.props, { + uniqueId: this.uniqueId + }) + + return + } + } + + hoistNonReactStatics(EnhancedComponent, WrappedComponent) + + return EnhancedComponent + } +} + +export default withUniqueId diff --git a/ui/src/components/external/FieldError.js b/ui/src/components/external/FieldError.js new file mode 100644 index 0000000..de8a8b8 --- /dev/null +++ b/ui/src/components/external/FieldError.js @@ -0,0 +1,61 @@ +import injectSheet from 'react-jss' +import PropTypes from 'prop-types' +import React from 'react' + +import * as mixins from 'styles/mixins' +import { FieldErrorText } from 'components/external/typography' + +function FieldError({ error, classes }) { + if (!error) { + return null + } + + return ( +
+
+ + {error} + +
+ ) +} + +FieldError.propTypes = { + error: PropTypes.oneOfType([ PropTypes.string, PropTypes.bool ]) +} + +FieldError.defaultProps = { + error: null +} + +export default injectSheet(({ colors, units }) => ({ + fieldError: { + backgroundColor: colors.externalFieldErrorBackground, + color: colors.text_primary, + marginLeft: units.externalFieldErrorShiftLeft, + marginTop: units.externalFieldErrorShiftTop, + paddingTop: units.externalFieldErrorVerticalPadding, + paddingRight: units.externalFieldErrorHorizontalPadding, + paddingBottom: units.externalFieldErrorVerticalPadding, + paddingLeft: units.externalFieldErrorHorizontalPadding, + position: 'absolute', + top: '100%' + }, + arrow: { + ...mixins.size(units.externalFieldErrorArrowSize), + + position: 'absolute', + top: -0.5 * units.externalFieldErrorArrowSize, + left: units.externalFieldErrorArrowShiftHorizontal, + + '&::before': { + ...mixins.size('100%'), + + backgroundColor: colors.externalFieldErrorBackground, + borderRadius: 1, + content: '" "', + display: 'block', + transform: `translateY(${units.externalFieldErrorArrowShiftVertical}px) rotate(45deg)` + } + } +}))(FieldError) diff --git a/ui/src/components/external/Footer.js b/ui/src/components/external/Footer.js new file mode 100644 index 0000000..59d0424 --- /dev/null +++ b/ui/src/components/external/Footer.js @@ -0,0 +1,71 @@ +import injectSheet from 'react-jss' +import React from 'react' + +import * as mixins from 'styles/mixins' +import GridContainer from 'components/external/GridContainer' +import GridItem from 'components/external/GridItem' +import { FooterLink, FooterText } from 'components/external/typography' + +function Footer({ classes }) { + return ( + + +
+
+ + Terms of Service + +
+ + Privacy Policy + +
+ + + © + {' '} + {new Date().getFullYear()} + {' '} + KeepWorks Technologies Pvt. Ltd. + +
+
+
+ ) +} + +export default injectSheet(({ colors, units }) => ({ + footerTextWrapper: { + ...mixins.responsiveProperties({ + alignItems: { xs: 'center' }, + flexDirection: { xs: 'column', xl: 'row' }, + justifyContent: { xs: 'center', xl: 'space-between' }, + paddingTop: units.externalFooterPaddingTopResponsive, + paddingBottom: units.externalFooterPaddingBottomResponsive + }), + + display: 'flex', + + '& a': { + ...mixins.responsiveProperty('marginRight', units.externalFooterLinksMarginRightResponsive), + + '&:last-of-type': { + marginRight: 0 + } + } + }, + footerLinkWrapper: { + ...mixins.responsiveProperty('marginBottom', units.externalFooterLinkWrapperMarginBottomResponsive), + + alignItems: 'center', + display: 'flex' + }, + verticalDivider: { + ...mixins.responsiveProperty('display', { xs: 'block', xl: 'none' }), + ...mixins.size(1, units.externalVerticalDividerHeight), + + backgroundColor: colors.externalVerticalDividerBackground, + marginRight: units.externalVerticalDividerHorizontalMargin, + marginLeft: units.externalVerticalDividerHorizontalMargin + } +}))(Footer) diff --git a/ui/src/components/external/GridContainer.js b/ui/src/components/external/GridContainer.js new file mode 100644 index 0000000..54b3795 --- /dev/null +++ b/ui/src/components/external/GridContainer.js @@ -0,0 +1,31 @@ +import injectSheet from 'react-jss' +import PropTypes from 'prop-types' +import React from 'react' + +import * as mixins from 'styles/mixins' + +function GridContainer({ classes, children }) { + return ( +
+ {children} +
+ ) +} + +GridContainer.propTypes = { + children: PropTypes.node.isRequired +} + +export default injectSheet(({ units }) => ({ + gridContainer: { + ...mixins.responsiveProperties({ + alignItems: { sm: 'center' }, + display: { xs: 'block', md: 'grid' }, + gridColumnGap: { sm: '20px' }, + marginRight: { xs: 20, md: 'auto' }, + marginLeft: { xs: 20, md: 'auto' }, + gridTemplateColumns: { md: 'repeat(15, 1fr)' }, + maxWidth: units.externalGridContainerMaxWidthResponsive + }) + } +}))(GridContainer) diff --git a/ui/src/components/external/GridItem.js b/ui/src/components/external/GridItem.js new file mode 100644 index 0000000..311475d --- /dev/null +++ b/ui/src/components/external/GridItem.js @@ -0,0 +1,29 @@ +import injectSheet from 'react-jss' +import PropTypes from 'prop-types' +import React from 'react' + +function GridItem({ classes, children }) { + return ( +
+ {children} +
+ ) +} + +GridItem.propTypes = { + start: PropTypes.oneOfType([ PropTypes.number, PropTypes.string ]), + end: PropTypes.oneOfType([ PropTypes.number, PropTypes.string ]), + children: PropTypes.node.isRequired +} + +GridItem.defaultProps = { + start: 1, + end: 16 +} + +export default injectSheet(() => ({ + gridItem: ({ start, end }) => ({ + gridColumnStart: start, + gridColumnEnd: end + }) +}))(GridItem) diff --git a/ui/src/components/external/Header.js b/ui/src/components/external/Header.js new file mode 100644 index 0000000..8748678 --- /dev/null +++ b/ui/src/components/external/Header.js @@ -0,0 +1,72 @@ +import injectSheet from 'react-jss' +import React, { Fragment } from 'react' +import { Link, matchPath } from 'react-router-dom' + +import * as mixins from 'styles/mixins' +import GridContainer from 'components/external/GridContainer' +import GridItem from 'components/external/GridItem' +import Logo from 'components/Logo' +import { NavLink } from 'components/external/typography' + +function Header({ classes }) { + const isHome = matchPath(document.location.pathname, { path: '/', exact: true }) + + const navLinks = ( + + + Log in + + + Sign up + + + ) + + const homeLink = ( + + + + + + + + + ) + + return ( + + +
+ {!isHome && homeLink} + {navLinks} +
+
+
+ ) +} + +export default injectSheet(({ units }) => ({ + header: { + ...mixins.responsiveProperties({ + paddingTop: units.externalHeaderPaddingTopResponsive, + paddingBottom: units.externalHeaderPaddingBottomResponsive + }), + + alignItems: 'center', + display: 'flex', + justifyContent: 'flex-end', + + '& > :not(:last-child)': { + marginRight: units.externalHeaderLinksMarginRight + }, + + '& $homeLink': { // To override :not(last-child) selector priority + marginRight: 'auto' + } + }, + homeLink: { + }, + homeLink_small: { + ...mixins.responsiveProperty('display', { xs: 'block', xl: 'none' }) + } +}))(Header) diff --git a/ui/src/components/external/buttons/SimpleButton.js b/ui/src/components/external/buttons/SimpleButton.js new file mode 100644 index 0000000..57c5411 --- /dev/null +++ b/ui/src/components/external/buttons/SimpleButton.js @@ -0,0 +1,56 @@ +import injectSheet from 'react-jss' +import PropTypes from 'prop-types' +import React from 'react' + +import * as mixins from 'styles/mixins' + +function SimpleButton({ disabled, label, classes }) { + return ( + + ) +} + +SimpleButton.propTypes = { + disabled: PropTypes.bool, + label: PropTypes.string.isRequired +} + +SimpleButton.defaultProps = { + disabled: false +} + +export default injectSheet(({ + colors, shadows, typography, units +}) => ({ + button: { + ...mixins.transitionSimple(), + ...typography.bold, + + backgroundColor: colors.externalButtonBackground, + borderRadius: units.externalButtonBorderRadius, + borderWidth: 0, + boxShadow: shadows.externalButton, + color: colors.text_darker, + cursor: 'pointer', + height: units.externalButtonHeight, + minWidth: units.externalButtonWidth, + textTransform: 'uppercase', + paddingTop: 0, + paddingRight: units.externalButtonHorizontalPadding, + paddingBottom: 0, + paddingLeft: units.externalButtonHorizontalPadding, + + '&[disabled]': { + backgroundColor: colors.externalButtonBackground_disabled, + boxShadow: 'none', + color: colors.text_primary, + pointerEvents: 'none' + }, + + '&:hover, &:focus': { + color: colors.text_primary + } + } +}))(SimpleButton) diff --git a/ui/src/components/external/inputs/TextInput.js b/ui/src/components/external/inputs/TextInput.js new file mode 100644 index 0000000..75b919b --- /dev/null +++ b/ui/src/components/external/inputs/TextInput.js @@ -0,0 +1,69 @@ +import injectSheet from 'react-jss' +import PropTypes from 'prop-types' +import React from 'react' + +import * as mixins from 'styles/mixins' +import cleanProps from 'lib/cleanProps' +import FieldError from 'components/external/FieldError' +import Input from 'components/inputs/TextInput' + +function TextInput({ + input, + meta, + type, + classes, + ...other +}) { + return ( +
+ + + +
+ ) +} + +TextInput.propTypes = { + type: PropTypes.string +} + +TextInput.defaultProps = { + type: 'text' +} + +export default injectSheet(({ + colors, typography, units +}) => ({ + wrapper: { + position: 'relative', + width: '100%' + }, + input: { + ...typography.regularSquishedResponsive, + + ...mixins.placeholder({ + ...mixins.transitionSimple(), + + color: colors.externalInputPlaceholder + }), + ...mixins.size('100%', units.externalInputHeight), + ...mixins.transitionSimple(), + + backgroundColor: colors.externalInputBackground, + borderColor: ({ meta }) => ( + Input.fieldError(meta) ? colors.externalFieldErrorBackground : 'transparent' + ), + borderRadius: units.externalInputBorderRadius, + borderStyle: 'solid', + borderWidth: 1, + color: colors.externalInputText, + paddingRight: units.externalInputPaddingRight, + paddingLeft: units.externalInputPaddingLeft, + + '&:focus': { + ...mixins.placeholder({ + opacity: 0.2 + }) + } + } +}))(TextInput) diff --git a/ui/src/components/external/typography/FieldErrorText.js b/ui/src/components/external/typography/FieldErrorText.js new file mode 100644 index 0000000..b01aa3a --- /dev/null +++ b/ui/src/components/external/typography/FieldErrorText.js @@ -0,0 +1,18 @@ +import React from 'react' + +import BaseText from 'components/typography/BaseText' + +function FieldErrorText({ children, ...other }) { + return ( + + {children} + + ) +} + +export default FieldErrorText diff --git a/ui/src/components/external/typography/FooterLink.js b/ui/src/components/external/typography/FooterLink.js new file mode 100644 index 0000000..f0c8b1d --- /dev/null +++ b/ui/src/components/external/typography/FooterLink.js @@ -0,0 +1,43 @@ +import injectSheet from 'react-jss' +import PropTypes from 'prop-types' +import React from 'react' +import { NavLink } from 'react-router-dom' + +import * as mixins from 'styles/mixins' + +function FooterLink({ to, children, classes }) { + return ( + + {children} + + ) +} + +FooterLink.propTypes = { + to: PropTypes.oneOfType([ PropTypes.string, PropTypes.object ]), + children: PropTypes.node.isRequired +} + +FooterLink.defaultProps = { + to: null +} + +export default injectSheet(({ colors, typography }) => ({ + footerLink: { + ...mixins.transitionSimple(), + ...typography.regularSmallSquishedResponsive, + + color: colors.text_pale, + + '&:hover': { + color: colors.text_darker + } + }, + footerLink_active: { + color: colors.text_primary, + + '&:hover': { + color: colors.text_primary + } + } +}))(FooterLink) diff --git a/ui/src/components/external/typography/FooterText.js b/ui/src/components/external/typography/FooterText.js new file mode 100644 index 0000000..1f0e4d4 --- /dev/null +++ b/ui/src/components/external/typography/FooterText.js @@ -0,0 +1,18 @@ +import React from 'react' + +import BaseText from 'components/typography/BaseText' + +function FooterText({ children, ...other }) { + return ( + + {children} + + ) +} + +export default FooterText diff --git a/ui/src/components/external/typography/Heading.js b/ui/src/components/external/typography/Heading.js new file mode 100644 index 0000000..dee2e25 --- /dev/null +++ b/ui/src/components/external/typography/Heading.js @@ -0,0 +1,28 @@ +import injectSheet from 'react-jss' +import React from 'react' + +import * as mixins from 'styles/mixins' +import BaseText from 'components/typography/BaseText' + +function Heading({ children, classes, ...other }) { + return ( + + {children} + + ) +} + +export default injectSheet(({ units }) => ({ + heading: { + ...mixins.responsiveProperties({ + marginTop: units.externalHeadingMarginTopResponsive, + width: units.externalHeadingWidthResponsive + }) + } +}))(Heading) diff --git a/ui/src/components/external/typography/NavLink.js b/ui/src/components/external/typography/NavLink.js new file mode 100644 index 0000000..4ad4979 --- /dev/null +++ b/ui/src/components/external/typography/NavLink.js @@ -0,0 +1,66 @@ +import classNames from 'classnames' +import injectSheet from 'react-jss' +import PropTypes from 'prop-types' +import React from 'react' +import { Link } from 'react-router-dom' + +import * as mixins from 'styles/mixins' + +function NavLink({ + className, isButton, to, children, classes +}) { + return ( + + {children} + + ) +} + +NavLink.propTypes = { + className: PropTypes.string, + isButton: PropTypes.bool, + to: PropTypes.oneOfType([ PropTypes.string, PropTypes.object ]), + children: PropTypes.node.isRequired +} + +NavLink.defaultProps = { + className: null, + isButton: false, + to: null +} + +export default injectSheet(({ + colors, shadows, typography, units +}) => ({ + navLink: { + ...mixins.animateUnderline({ bottom: -3 }), + ...typography.semiboldSmallResponsive, + + color: colors.text_light, + textTransform: 'uppercase' + }, + navLink_button: { + ...mixins.transitionSimple(), + ...typography.semiboldSmallResponsive, + + alignItems: 'center', + backgroundColor: '#fff', + borderRadius: units.externalButtonBorderRadius, + boxShadow: shadows.externalButton, + color: colors.text_darker, + cursor: 'pointer', + display: 'flex', + height: units.externalButtonHeight, + justifyContent: 'center', + minWidth: units.externalButtonWidth, + paddingTop: 0, + paddingRight: units.externalNavLinkHorizontalPadding_button, + paddingBottom: 0, + paddingLeft: units.externalNavLinkHorizontalPadding_button, + textTransform: 'uppercase', + + '&:hover, &:focus': { + color: colors.text_primary + } + } +}))(NavLink) diff --git a/ui/src/components/external/typography/PageHeading.js b/ui/src/components/external/typography/PageHeading.js new file mode 100644 index 0000000..de33189 --- /dev/null +++ b/ui/src/components/external/typography/PageHeading.js @@ -0,0 +1,28 @@ +import injectSheet from 'react-jss' +import React from 'react' + +import * as mixins from 'styles/mixins' +import BaseText from 'components/typography/BaseText' + +function PageHeading({ children, classes, ...other }) { + return ( + + {children} + + ) +} + +export default injectSheet(({ units }) => ({ + pageHeading: { + ...mixins.responsiveProperties({ + marginTop: units.externalPageHeadingMarginTopResponsive, + marginBottom: units.externalPageHeadingMarginBottomResponsive + }) + } +}))(PageHeading) diff --git a/ui/src/components/external/typography/PageLink.js b/ui/src/components/external/typography/PageLink.js new file mode 100644 index 0000000..1109130 --- /dev/null +++ b/ui/src/components/external/typography/PageLink.js @@ -0,0 +1,21 @@ +import injectSheet from 'react-jss' +import React from 'react' + +import * as mixins from 'styles/mixins' +import { TextLink as BaseTextLink } from 'components/typography' + +function PageLink({ classes, ...other }) { + return ( + + ) +} + +export default injectSheet(({ colors, typography }) => ({ + pageLink: { + ...mixins.animateUnderline({ color: colors.text_primary, bottom: -2 }), + ...typography.semiboldSquishedResponsive, + + color: colors.text_primary, + display: 'inline-block' + } +}))(PageLink) diff --git a/ui/src/components/external/typography/PageList.js b/ui/src/components/external/typography/PageList.js new file mode 100644 index 0000000..0374fdb --- /dev/null +++ b/ui/src/components/external/typography/PageList.js @@ -0,0 +1,22 @@ +import injectSheet from 'react-jss' +import React from 'react' + +function PageList({ children, classes }) { + return ( +
    + {children} +
+ ) +} + +export default injectSheet(({ units }) => ({ + list: { + listStyle: 'none', + margin: 0, + padding: 0, + + '& + *': { + marginTop: units.externalParagraphMarginTop + } + } +}))(PageList) diff --git a/ui/src/components/external/typography/PageListItem.js b/ui/src/components/external/typography/PageListItem.js new file mode 100644 index 0000000..878d600 --- /dev/null +++ b/ui/src/components/external/typography/PageListItem.js @@ -0,0 +1,36 @@ +import injectSheet from 'react-jss' +import React from 'react' + +import * as mixins from 'styles/mixins' +import { PageListText } from 'components/external/typography' + +function PageListItem({ children, classes }) { + return ( +
  • + + {children} + +
  • + ) +} + +export default injectSheet(({ colors, typography, units }) => ({ + listItem: { + position: 'relative', + paddingLeft: units.externalListItemBulletSize + units.externalListItemPaddingLeft, + + '&::before': { + ...mixins.size(units.externalListItemBulletSize), + + backgroundColor: colors.externalListItemBulletColor, + borderRadius: '50%', + content: '" "', + position: 'absolute', + top: ( + (typography.medium.lineHeight * typography.medium.fontSize) + - units.externalListItemBulletSize + ) / 2, + left: 0 + } + } +}))(PageListItem) diff --git a/ui/src/components/external/typography/PageListText.js b/ui/src/components/external/typography/PageListText.js new file mode 100644 index 0000000..accbdc9 --- /dev/null +++ b/ui/src/components/external/typography/PageListText.js @@ -0,0 +1,18 @@ +import React from 'react' + +import BaseText from 'components/typography/BaseText' + +function PageListText({ children, ...other }) { + return ( + + {children} + + ) +} + +export default PageListText diff --git a/ui/src/components/external/typography/PageSubHeading.js b/ui/src/components/external/typography/PageSubHeading.js new file mode 100644 index 0000000..adaab76 --- /dev/null +++ b/ui/src/components/external/typography/PageSubHeading.js @@ -0,0 +1,26 @@ +import injectSheet from 'react-jss' +import React from 'react' + +import BaseText from 'components/typography/BaseText' + +function PageSubHeading({ classes, children, ...other }) { + return ( + + {children} + + ) +} + +export default injectSheet(({ units }) => ({ + pageText: { + '& + *': { + marginTop: units.externalParagraphMarginTop + } + } +}))(PageSubHeading) diff --git a/ui/src/components/external/typography/PageText.js b/ui/src/components/external/typography/PageText.js new file mode 100644 index 0000000..70f94c9 --- /dev/null +++ b/ui/src/components/external/typography/PageText.js @@ -0,0 +1,26 @@ +import injectSheet from 'react-jss' +import React from 'react' + +import BaseText from 'components/typography/BaseText' + +function PageText({ classes, children, ...other }) { + return ( + + {children} + + ) +} + +export default injectSheet(({ units }) => ({ + pageText: { + '& + *': { + marginTop: units.externalParagraphMarginTop + } + } +}))(PageText) diff --git a/ui/src/components/external/typography/Text.js b/ui/src/components/external/typography/Text.js new file mode 100644 index 0000000..3d641e8 --- /dev/null +++ b/ui/src/components/external/typography/Text.js @@ -0,0 +1,24 @@ +import injectSheet from 'react-jss' +import React from 'react' + +import BaseText from 'components/typography/BaseText' + +function Text({ classes, children, ...other }) { + return ( + + {children} + + ) +} + +export default injectSheet(({ units }) => ({ + text: { + marginTop: units.externalTextMarginTop + } +}))(Text) diff --git a/ui/src/components/external/typography/TextLink.js b/ui/src/components/external/typography/TextLink.js new file mode 100644 index 0000000..89aff0b --- /dev/null +++ b/ui/src/components/external/typography/TextLink.js @@ -0,0 +1,22 @@ +import injectSheet from 'react-jss' +import React from 'react' + +import * as mixins from 'styles/mixins' +import { TextLink as BaseTextLink } from 'components/typography' + +function TextLink({ classes, ...other }) { + return ( + + ) +} + +export default injectSheet(({ colors, typography, units }) => ({ + textLink: { + ...mixins.animateUnderline({ color: colors.text_primary, bottom: -3 }), + ...typography.semiboldSmallSquished, + + color: colors.text_primary, + display: 'inline-block', + marginTop: units.externalTextLinkMarginTop + } +}))(TextLink) diff --git a/ui/src/components/external/typography/Title.js b/ui/src/components/external/typography/Title.js new file mode 100644 index 0000000..d470171 --- /dev/null +++ b/ui/src/components/external/typography/Title.js @@ -0,0 +1,18 @@ +import React from 'react' + +import BaseText from 'components/typography/BaseText' + +function Title({ children, ...other }) { + return ( + + {children} + + ) +} + +export default Title diff --git a/ui/src/components/external/typography/index.js b/ui/src/components/external/typography/index.js new file mode 100644 index 0000000..48a043a --- /dev/null +++ b/ui/src/components/external/typography/index.js @@ -0,0 +1,15 @@ +export { default as FieldErrorText } from './FieldErrorText' +export { default as FooterLink } from './FooterLink' +export { default as FooterText } from './FooterText' +export { default as Heading } from './Heading' +export { default as NavLink } from './NavLink' +export { default as PageHeading } from './PageHeading' +export { default as PageLink } from './PageLink' +export { default as PageList } from './PageList' +export { default as PageListItem } from './PageListItem' +export { default as PageListText } from './PageListText' +export { default as PageSubHeading } from './PageSubHeading' +export { default as PageText } from './PageText' +export { default as Text } from './Text' +export { default as TextLink } from './TextLink' +export { default as Title } from './Title' diff --git a/ui/src/components/inputs/TextInput.js b/ui/src/components/inputs/TextInput.js new file mode 100644 index 0000000..5ae3d50 --- /dev/null +++ b/ui/src/components/inputs/TextInput.js @@ -0,0 +1,262 @@ +import classNames from 'classnames' +import injectSheet from 'react-jss' +import PropTypes from 'prop-types' +import React from 'react' + +import * as mixins from 'styles/mixins' +import Badge from 'components/internal/Badge' +import cleanProps from 'lib/cleanProps' +import FieldError from 'components/FieldError' +import FieldHint from 'components/FieldHint' +import FontIcon from 'components/FontIcon' + +function TextInput({ + badge, + disabled, + hint, + icon, + isMultiline, + input, + label, + meta, + spaced, + stretched, + type, + classes, + ...other +}) { + if (label && icon) { + throw new Error('You can pass either `icon` or `label` to TextInput') + } + + const error = TextInput.fieldError(meta) + + const renderIcon = () => icon && + + const renderAlertIcon = () => error && + + const renderBadge = () => badge && {badge} + + const renderLabel = () => label && ( + + ) + + return ( +
    +
    + {renderIcon()} + {renderAlertIcon() || renderBadge()} + {renderLabel()} + + {isMultiline ? ( +