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 0000000..7951d1a Binary files /dev/null and b/api/fixtures/assets/logo.png differ 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 0000000..e62d103 Binary files /dev/null and b/ui/src/assets/fonts/claycms-icons.woff differ diff --git a/ui/src/assets/fonts/claycms-icons.woff2 b/ui/src/assets/fonts/claycms-icons.woff2 new file mode 100755 index 0000000..62e8cd0 Binary files /dev/null and b/ui/src/assets/fonts/claycms-icons.woff2 differ 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 0000000..7d76385 Binary files /dev/null and b/ui/src/assets/images/external/page-circle.png differ 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 0000000..383d452 Binary files /dev/null and b/ui/src/assets/images/external/page-circle@2x.png differ diff --git a/ui/src/assets/images/favicon.png b/ui/src/assets/images/favicon.png new file mode 100644 index 0000000..105f360 Binary files /dev/null and b/ui/src/assets/images/favicon.png differ diff --git a/ui/src/assets/images/loader.gif b/ui/src/assets/images/loader.gif new file mode 100644 index 0000000..6960011 Binary files /dev/null and b/ui/src/assets/images/loader.gif differ diff --git a/ui/src/assets/images/logo-symbol-color.png b/ui/src/assets/images/logo-symbol-color.png new file mode 100644 index 0000000..75488ab Binary files /dev/null and b/ui/src/assets/images/logo-symbol-color.png differ 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 0000000..b9ed2a0 Binary files /dev/null and b/ui/src/assets/images/logo-symbol-color@2x.png differ diff --git a/ui/src/assets/images/logo-text-color.png b/ui/src/assets/images/logo-text-color.png new file mode 100644 index 0000000..433ba17 Binary files /dev/null and b/ui/src/assets/images/logo-text-color.png differ diff --git a/ui/src/assets/images/logo-text-color@2x.png b/ui/src/assets/images/logo-text-color@2x.png new file mode 100644 index 0000000..7951d1a Binary files /dev/null and b/ui/src/assets/images/logo-text-color@2x.png differ 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 0000000..7153ec8 Binary files /dev/null and b/ui/src/assets/images/logo-text-white.png differ 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 0000000..8bd5314 Binary files /dev/null and b/ui/src/assets/images/logo-text-white@2x.png differ diff --git a/ui/src/assets/stylesheets/fonts.css b/ui/src/assets/stylesheets/fonts.css new file mode 100644 index 0000000..ce7db65 --- /dev/null +++ b/ui/src/assets/stylesheets/fonts.css @@ -0,0 +1,5 @@ +@font-face { + font-family: 'claycms-icons'; + src: url('~fonts/claycms-icons.woff2') format('woff2'), + url('~fonts/claycms-icons.woff') format('woff'); +} diff --git a/ui/src/assets/stylesheets/globals.css b/ui/src/assets/stylesheets/globals.css new file mode 100644 index 0000000..4f40d8f --- /dev/null +++ b/ui/src/assets/stylesheets/globals.css @@ -0,0 +1,44 @@ +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + box-sizing: border-box; +} + +html, +body { + margin: 0; +} + +html, +body, +#root, +[data-reactroot] { + height: 100%; +} + +body { + background-color: #fff; +} + +p, +h1, +h2, +h3, +h4, +h5, +h6, +ul { + margin: 0; +} + +a, +button, +div, +input, +textarea { + outline: none; +} + +a { + text-decoration: none; +} diff --git a/ui/src/assets/stylesheets/icons.css b/ui/src/assets/stylesheets/icons.css new file mode 100644 index 0000000..202464c --- /dev/null +++ b/ui/src/assets/stylesheets/icons.css @@ -0,0 +1,431 @@ +.icon, +[class^="icon-"]:before, +[class*=" icon-"]:before { + font-family: "claycms-icons" !important; + font-style: normal !important; + font-weight: normal !important; + font-variant: normal !important; + text-transform: none !important; + speak: none; + line-height: 1; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.icon-email:before { + content: "\e000"; +} +.icon-first-name:before { + content: "\e001"; +} +.icon-last-name:before { + content: "\e002"; +} +.icon-password:before { + content: "\e003"; +} +.icon-alert-success:before { + content: "\e004"; +} +.icon-alert-failure:before { + content: "\e005"; +} +.icon-cross:before { + content: "\e006"; +} +.icon-round-alert:before { + content: "\e007"; +} +.icon-billing:before { + content: "\e00a"; +} +.icon-blimp:before { + content: "\e00b"; +} +.icon-camera:before { + content: "\e00c"; +} +.icon-code:before { + content: "\e00d"; +} +.icon-connect:before { + content: "\e00e"; +} +.icon-credit:before { + content: "\e00f"; +} +.icon-credit-card:before { + content: "\e010"; +} +.icon-invoice:before { + content: "\e011"; +} +.icon-invite-user:before { + content: "\e012"; +} +.icon-route:before { + content: "\e013"; +} +.icon-hot-air-balloon:before { + content: "\e014"; +} +.icon-gift:before { + content: "\e015"; +} +.icon-filter:before { + content: "\e016"; +} +.icon-event:before { + content: "\e017"; +} +.icon-edit:before { + content: "\e018"; +} +.icon-delivery:before { + content: "\e019"; +} +.icon-dashboard:before { + content: "\e01a"; +} +.icon-customer:before { + content: "\e01b"; +} +.icon-layout:before { + content: "\e01c"; +} +.icon-list:before { + content: "\e01d"; +} +.icon-lock:before { + content: "\e01e"; +} +.icon-notification-bell:before { + content: "\e01f"; +} +.icon-notification-push:before { + content: "\e020"; +} +.icon-notification-rules:before { + content: "\e021"; +} +.icon-notification-sms:before { + content: "\e022"; +} +.icon-person:before { + content: "\e023"; +} +.icon-plan:before { + content: "\e024"; +} +.icon-plus:before { + content: "\e025"; +} +.icon-project:before { + content: "\e026"; +} +.icon-trash:before { + content: "\e027"; +} +.icon-team:before { + content: "\e029"; +} +.icon-snippet:before { + content: "\e02a"; +} +.icon-setting:before { + content: "\e02b"; +} +.icon-round-remove:before { + content: "\e02c"; +} +.icon-round-plus:before { + content: "\e02d"; +} +.icon-search:before { + content: "\e02e"; +} +.icon-arrow-left-thin:before { + content: "\e02f"; +} +.icon-tick:before { + content: "\e030"; +} +.icon-round-tick:before { + content: "\e031"; +} +.icon-round-info:before { + content: "\e032"; +} +.icon-slide-right:before { + content: "\e033"; +} +.icon-notification-rules-thin:before { + content: "\e034"; +} +.icon-email-thin:before { + content: "\e035"; +} +.icon-chat:before { + content: "\e036"; +} +.icon-phone:before { + content: "\e037"; +} +.icon-arrow-right-small:before { + content: "\e038"; +} +.icon-arrow-left-small:before { + content: "\e039"; +} +.icon-arrow-down-small:before { + content: "\e03a"; +} +.icon-arrow-up-small:before { + content: "\e03b"; +} +.icon-tag:before { + content: "\e03c"; +} +.icon-checkbox:before { + content: "\e03d"; +} +.icon-attachment:before { + content: "\e03e"; +} +.icon-reply:before { + content: "\e03f"; +} +.icon-email-subject:before { + content: "\e040"; +} +.icon-from:before { + content: "\e041"; +} +.icon-email-preview:before { + content: "\e042"; +} +.icon-round-pause:before { + content: "\e043"; +} +.icon-email-rounded:before { + content: "\e046"; +} +.icon-newsletter:before { + content: "\e047"; +} +.icon-send:before { + content: "\e048"; +} +.icon-download:before { + content: "\e049"; +} +.icon-round-send:before { + content: "\e04c"; +} +.icon-template-unlink:before { + content: "\e04d"; +} +.icon-template-underline:before { + content: "\e04e"; +} +.icon-template-text-color:before { + content: "\e04f"; +} +.icon-template-text:before { + content: "\e050"; +} +.icon-template-superscript:before { + content: "\e051"; +} +.icon-template-subscript:before { + content: "\e052"; +} +.icon-template-background-color:before { + content: "\e053"; +} +.icon-template-bold:before { + content: "\e054"; +} +.icon-template-button:before { + content: "\e055"; +} +.icon-template-code:before { + content: "\e056"; +} +.icon-template-cross:before { + content: "\e057"; +} +.icon-template-hyperlink:before { + content: "\e059"; +} +.icon-template-image:before { + content: "\e05a"; +} +.icon-template-italic:before { + content: "\e05b"; +} +.icon-template-link:before { + content: "\e05c"; +} +.icon-template-list-bullet:before { + content: "\e05d"; +} +.icon-template-align-center:before { + content: "\e05e"; +} +.icon-template-align-justify:before { + content: "\e05f"; +} +.icon-template-align-left:before { + content: "\e060"; +} +.icon-template-align-right:before { + content: "\e061"; +} +.icon-template-strikethrough:before { + content: "\e062"; +} +.icon-template-list-numbers:before { + content: "\e063"; +} +.icon-template-grid:before { + content: "\e058"; +} +.icon-template-square:before { + content: "\e064"; +} +.icon-template-display-normal:before { + content: "\e065"; +} +.icon-template-display-preview:before { + content: "\e066"; +} +.icon-template-variable:before { + content: "\e067"; +} +.icon-template-table:before { + content: "\e068"; +} +.icon-template-test:before { + content: "\e04a"; +} +.icon-template-build:before { + content: "\e04b"; +} +.icon-balloon:before { + content: "\e009"; +} +.icon-template-file:before { + content: "\e044"; +} +.icon-template-eraser:before { + content: "\e069"; +} +.icon-android:before { + content: "\e06a"; +} +.icon-apple:before { + content: "\e06b"; +} +.icon-body:before { + content: "\e06c"; +} +.icon-generic:before { + content: "\e06d"; +} +.icon-upload:before { + content: "\e06e"; +} +.icon-stopwatch:before { + content: "\e06f"; +} +.icon-warning:before { + content: "\e070"; +} +.icon-widget:before { + content: "\e071"; +} +.icon-title:before { + content: "\e072"; +} +.icon-image:before { + content: "\e073"; +} +.icon-image-thin:before { + content: "\e074"; +} +.icon-wifi:before { + content: "\e075"; +} +.icon-network:before { + content: "\e076"; +} +.icon-battery:before { + content: "\e077"; +} +.icon-drag-and-drop:before { + content: "\e078"; +} +.icon-code-thin:before { + content: "\e079"; +} +.icon-star:before { + content: "\e045"; +} +.icon-template:before { + content: "\e008"; +} +.icon-arrow-down:before { + content: "\e028"; +} +.icon-template-spacer:before { + content: "\e07a"; +} +.icon-template-move-thin:before { + content: "\e07b"; +} +.icon-template-move-thick:before { + content: "\e07c"; +} +.icon-template-divider:before { + content: "\e07d"; +} +.icon-template-shift-layer:before { + content: "\e07e"; +} +.icon-campaign:before { + content: "\e07f"; +} +.icon-flow:before { + content: "\e080"; +} +.icon-segment:before { + content: "\e081"; +} +.icon-report:before { + content: "\e082"; +} +.icon-log:before { + content: "\e083"; +} +.icon-archive:before { + content: "\e084"; +} +.icon-calendar:before { + content: "\e085"; +} +.icon-comprising:before { + content: "\e086"; +} +.icon-notification-status:before { + content: "\e087"; +} +.icon-notification-channel:before { + content: "\e088"; +} +.icon-sent:before { + content: "\e089"; +} +.icon-received:before { + content: "\e08a"; +} diff --git a/ui/src/assets/stylesheets/main.css b/ui/src/assets/stylesheets/main.css new file mode 100644 index 0000000..e5ba773 --- /dev/null +++ b/ui/src/assets/stylesheets/main.css @@ -0,0 +1,11 @@ +@import url('https://fonts.googleapis.com/css?family=Poppins:200,300,500,400,600,700'); +@import url('https://fonts.googleapis.com/css?family=Roboto:700,900'); +@import '~node_modules/@uppy/core/dist/style.css'; +@import '~node_modules/@uppy/dashboard/dist/style.css'; +@import '~node_modules/normalize.css/normalize.css'; + +@import 'react_sortablejs.css'; + +@import 'fonts.css'; +@import 'icons.css'; +@import 'globals.css'; diff --git a/ui/src/assets/stylesheets/react_sortablejs.css b/ui/src/assets/stylesheets/react_sortablejs.css new file mode 100644 index 0000000..2693d0f --- /dev/null +++ b/ui/src/assets/stylesheets/react_sortablejs.css @@ -0,0 +1,11 @@ +.sortable-ghost { + opacity: 0.5; +} + +.drag-handle { + cursor: grab; +} + +.drag-handle:active { + cursor: grabbing; +} diff --git a/ui/src/client/authLink.js b/ui/src/client/authLink.js new file mode 100644 index 0000000..22f1f1b --- /dev/null +++ b/ui/src/client/authLink.js @@ -0,0 +1,20 @@ +import { ApolloLink } from 'apollo-link' + +import cache from 'client/cache' + +import GET_TOKEN from 'queries/session' + +const authLink = new ApolloLink((operation, forward) => { + 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 ? ( +