diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index 125bfdf..0000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,56 +0,0 @@
-version: 2
-
-.steps_definition: &steps_definition
- steps:
- - restore_cache:
- keys:
- - v{{ .Environment.CIRCLE_CACHE_VERSION }}-git-{{ .Branch }}-{{ .Revision }}
- - v{{ .Environment.CIRCLE_CACHE_VERSION }}-git-{{ .Branch }}-
- - v{{ .Environment.CIRCLE_CACHE_VERSION }}-git-
- - checkout
- - save_cache:
- key: v{{ .Environment.CIRCLE_CACHE_VERSION }}-git-{{ .Branch }}-{{ .Revision }}
- paths:
- - ".git"
- - restore_cache:
- keys:
- - v{{ .Environment.CIRCLE_CACHE_VERSION }}-{{arch}}-gem-lock-{{ checksum "Gemfile.lock" }}
- - v{{ .Environment.CIRCLE_CACHE_VERSION }}-{{arch}}-gem-lock-
- - run: gem install bundler
- - run: bundle config github.https true
- - run: bundle check --path=vendor/bundle || bundle install --path vendor/bundle
- - save_cache:
- key: v{{ .Environment.CIRCLE_CACHE_VERSION }}-{{arch}}-gem-lock-{{ checksum "Gemfile.lock" }}
- paths:
- - vendor/bundle
- - run: gem install bundler
- - run: bundle config github.https true
- # Setup the environment
- - run: cp config/database.yml.example config/database.yml
- - run: cp config/secrets.yml.example config/secrets.yml
- - run: |
- bundle exec rake db:migrate
- bundle exec rspec
-
-jobs:
- specs:
- docker:
- - image: circleci/ruby:2.7.2-node-browsers
- <<: *steps_definition
-
-workflows:
- version: 2
- build:
- jobs:
- - specs
-
- nightly:
- triggers:
- - schedule:
- cron: '0 0 * * *'
- filters:
- branches:
- only:
- - master
- jobs:
- - specs
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..3604a47
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,20 @@
+.git
+.gitignore
+.circleci
+.ruby-lsp
+/log/*
+/tmp/*
+/db/*.sqlite3
+/vendor/bundle
+/node_modules
+/coverage
+/coverage.data
+/public/assets
+/public/packs
+/spec
+/config/database.yml
+/config/secrets.yml
+.env
+.env.*
+!.env.example
+README.md
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..dc99ade
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,15 @@
+# Copy this file to .env and fill in your Castle credentials.
+# Grab them from the Castle dashboard: https://dashboard.castle.io (Settings -> API).
+
+# Server-side API secret, used by the castle-rb SDK.
+CASTLE_API_SECRET=
+
+# Publishable key, used by the browser SDK to mint request tokens.
+CASTLE_PK=
+
+# Optional: Twitter/X OAuth credentials for the social login demo.
+TWITTER_APP_ID=
+TWITTER_SECRET=
+
+# Required in production only (generate with `bin/rails secret`).
+# SECRET_KEY_BASE=
diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml
new file mode 100644
index 0000000..6c3f6ea
--- /dev/null
+++ b/.github/workflows/specs.yml
@@ -0,0 +1,28 @@
+name: Specs
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+
+jobs:
+ specs:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ # Reads the version from .ruby-version and installs the bundler
+ # pinned in Gemfile.lock (BUNDLED WITH).
+ bundler-cache: true
+
+ - name: Set up the test database
+ run: |
+ cp config/database.yml.example config/database.yml
+ bundle exec rails db:test:prepare
+
+ - name: Run the test suite
+ run: bundle exec rspec
diff --git a/.gitignore b/.gitignore
index c9c0acc..b88308d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,8 +12,15 @@ db/*.sqlite3
.byebug_history
.redcar/
.sass-cache
+db/*.sqlite3-*
/config/*.yml
+!/config/storage.yml
/config/secrets.yml
+
+# Local environment variables (secrets). Keep .env.example tracked.
+/.env
+/.env.*
+!/.env.example
/coverage.data
/db/*.javadb/
/db/*.sqlite3
diff --git a/.ruby-version b/.ruby-version
index 2eb2fe9..7bcbb38 100644
--- a/.ruby-version
+++ b/.ruby-version
@@ -1 +1 @@
-ruby-2.7.2
+3.4.9
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..ece6878
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,66 @@
+# syntax=docker/dockerfile:1
+
+# ---------- Build stage ----------
+FROM ruby:3.4.9-slim AS build
+
+ENV RAILS_ENV=production \
+ BUNDLE_DEPLOYMENT=1 \
+ BUNDLE_WITHOUT=development:test \
+ BUNDLE_PATH=/usr/local/bundle \
+ BUNDLER_VERSION=2.7.2
+
+RUN apt-get update -qq && \
+ apt-get install --no-install-recommends -y build-essential git libsqlite3-dev libyaml-dev pkg-config && \
+ rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY Gemfile Gemfile.lock .ruby-version ./
+RUN gem install bundler -v "${BUNDLER_VERSION}" && \
+ bundle install && \
+ rm -rf "${BUNDLE_PATH}"/ruby/*/cache
+
+COPY . .
+
+# The real database.yml is environment-specific and git-ignored; derive it from
+# the committed example so the build is reproducible from a clean checkout.
+RUN cp config/database.yml.example config/database.yml
+
+# Precompile bootsnap and assets. SECRET_KEY_BASE_DUMMY lets us build assets
+# without baking a real secret into the image.
+RUN bundle exec bootsnap precompile app/ lib/ || true && \
+ SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile
+
+# ---------- Runtime stage ----------
+FROM ruby:3.4.9-slim AS runtime
+
+ENV RAILS_ENV=production \
+ BUNDLE_DEPLOYMENT=1 \
+ BUNDLE_WITHOUT=development:test \
+ BUNDLE_PATH=/usr/local/bundle \
+ BUNDLER_VERSION=2.7.2 \
+ RAILS_SERVE_STATIC_FILES=1 \
+ RAILS_LOG_TO_STDOUT=1 \
+ PORT=3000
+
+RUN apt-get update -qq && \
+ apt-get install --no-install-recommends -y libsqlite3-0 libyaml-0-2 && \
+ rm -rf /var/lib/apt/lists/* && \
+ gem install bundler -v "${BUNDLER_VERSION}"
+
+# Run as an unprivileged user.
+RUN groupadd --system --gid 1000 rails && \
+ useradd --system --uid 1000 --gid 1000 --create-home rails
+
+WORKDIR /app
+
+COPY --from=build /usr/local/bundle /usr/local/bundle
+COPY --from=build /app /app
+
+RUN mkdir -p db log tmp && chown -R rails:rails db log tmp
+USER rails
+
+EXPOSE 3000
+
+ENTRYPOINT ["./bin/docker-entrypoint"]
+CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
diff --git a/Gemfile b/Gemfile
index 6253c36..cfa068b 100644
--- a/Gemfile
+++ b/Gemfile
@@ -2,22 +2,23 @@
source 'https://rubygems.org'
-ruby '2.7.2'
+ruby file: '.ruby-version'
-gem 'bootstrap'
-gem 'castle-rb'
-gem 'devise'
-gem 'hamlit'
-gem 'jquery-rails'
+gem 'bootsnap', require: false
+gem 'bootstrap', '~> 5.3'
+gem 'castle-rb', '~> 8.1'
+gem 'devise', '~> 5.0'
+gem 'dotenv-rails'
+gem 'hamlit-rails'
+gem 'omniauth-rails_csrf_protection'
gem 'omniauth-twitter'
-gem 'puma'
-gem 'rails'
-gem 'rails-ujs'
+gem 'puma', '~> 6.4'
+gem 'rails', '~> 8.1.3'
gem 'responders'
-gem 'sass-rails'
+gem 'sassc-rails'
gem 'simple_form'
-gem 'sqlite3'
-gem 'uglifier'
+gem 'sprockets-rails'
+gem 'sqlite3', '~> 2.1'
group :development, :test do
gem 'byebug'
@@ -25,5 +26,9 @@ group :development, :test do
gem 'faker'
gem 'rails-controller-testing'
gem 'rspec-rails'
- gem 'simplecov'
+ gem 'simplecov', require: false
+end
+
+group :development do
+ gem 'web-console'
end
diff --git a/Gemfile.lock b/Gemfile.lock
index 800bea1..15795b0 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,195 +1,288 @@
GEM
remote: https://rubygems.org/
specs:
- actioncable (6.1.0)
- actionpack (= 6.1.0)
- activesupport (= 6.1.0)
+ action_text-trix (2.1.19)
+ railties
+ actioncable (8.1.3)
+ actionpack (= 8.1.3)
+ activesupport (= 8.1.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
- actionmailbox (6.1.0)
- actionpack (= 6.1.0)
- activejob (= 6.1.0)
- activerecord (= 6.1.0)
- activestorage (= 6.1.0)
- activesupport (= 6.1.0)
- mail (>= 2.7.1)
- actionmailer (6.1.0)
- actionpack (= 6.1.0)
- actionview (= 6.1.0)
- activejob (= 6.1.0)
- activesupport (= 6.1.0)
- mail (~> 2.5, >= 2.5.4)
- rails-dom-testing (~> 2.0)
- actionpack (6.1.0)
- actionview (= 6.1.0)
- activesupport (= 6.1.0)
- rack (~> 2.0, >= 2.0.9)
+ zeitwerk (~> 2.6)
+ actionmailbox (8.1.3)
+ actionpack (= 8.1.3)
+ activejob (= 8.1.3)
+ activerecord (= 8.1.3)
+ activestorage (= 8.1.3)
+ activesupport (= 8.1.3)
+ mail (>= 2.8.0)
+ actionmailer (8.1.3)
+ actionpack (= 8.1.3)
+ actionview (= 8.1.3)
+ activejob (= 8.1.3)
+ activesupport (= 8.1.3)
+ mail (>= 2.8.0)
+ rails-dom-testing (~> 2.2)
+ actionpack (8.1.3)
+ actionview (= 8.1.3)
+ activesupport (= 8.1.3)
+ nokogiri (>= 1.8.5)
+ rack (>= 2.2.4)
+ rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
- rails-dom-testing (~> 2.0)
- rails-html-sanitizer (~> 1.0, >= 1.2.0)
- actiontext (6.1.0)
- actionpack (= 6.1.0)
- activerecord (= 6.1.0)
- activestorage (= 6.1.0)
- activesupport (= 6.1.0)
+ rails-dom-testing (~> 2.2)
+ rails-html-sanitizer (~> 1.6)
+ useragent (~> 0.16)
+ actiontext (8.1.3)
+ action_text-trix (~> 2.1.15)
+ actionpack (= 8.1.3)
+ activerecord (= 8.1.3)
+ activestorage (= 8.1.3)
+ activesupport (= 8.1.3)
+ globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
- actionview (6.1.0)
- activesupport (= 6.1.0)
+ actionview (8.1.3)
+ activesupport (= 8.1.3)
builder (~> 3.1)
- erubi (~> 1.4)
- rails-dom-testing (~> 2.0)
- rails-html-sanitizer (~> 1.1, >= 1.2.0)
- activejob (6.1.0)
- activesupport (= 6.1.0)
+ erubi (~> 1.11)
+ rails-dom-testing (~> 2.2)
+ rails-html-sanitizer (~> 1.6)
+ activejob (8.1.3)
+ activesupport (= 8.1.3)
globalid (>= 0.3.6)
- activemodel (6.1.0)
- activesupport (= 6.1.0)
- activerecord (6.1.0)
- activemodel (= 6.1.0)
- activesupport (= 6.1.0)
- activestorage (6.1.0)
- actionpack (= 6.1.0)
- activejob (= 6.1.0)
- activerecord (= 6.1.0)
- activesupport (= 6.1.0)
- marcel (~> 0.3.1)
- mimemagic (~> 0.3.2)
- activesupport (6.1.0)
- concurrent-ruby (~> 1.0, >= 1.0.2)
+ activemodel (8.1.3)
+ activesupport (= 8.1.3)
+ activerecord (8.1.3)
+ activemodel (= 8.1.3)
+ activesupport (= 8.1.3)
+ timeout (>= 0.4.0)
+ activestorage (8.1.3)
+ actionpack (= 8.1.3)
+ activejob (= 8.1.3)
+ activerecord (= 8.1.3)
+ activesupport (= 8.1.3)
+ marcel (~> 1.0)
+ activesupport (8.1.3)
+ base64
+ bigdecimal
+ concurrent-ruby (~> 1.0, >= 1.3.1)
+ connection_pool (>= 2.2.5)
+ drb
i18n (>= 1.6, < 2)
+ json
+ logger (>= 1.4.2)
minitest (>= 5.1)
- tzinfo (~> 2.0)
- zeitwerk (~> 2.3)
- autoprefixer-rails (10.1.0.0)
- execjs
- bcrypt (3.1.16)
- bootstrap (4.5.3)
- autoprefixer-rails (>= 9.1.0)
- popper_js (>= 1.14.3, < 2)
- sassc-rails (>= 2.0.0)
- builder (3.2.4)
- byebug (11.1.3)
- castle-rb (5.0.0)
- concurrent-ruby (1.1.7)
+ securerandom (>= 0.3)
+ tzinfo (~> 2.0, >= 2.0.5)
+ uri (>= 0.13.1)
+ auth-sanitizer (0.1.4)
+ version_gem (~> 1.1, >= 1.1.9)
+ base64 (0.3.0)
+ bcrypt (3.1.22)
+ bigdecimal (4.1.2)
+ bindex (0.8.1)
+ bootsnap (1.24.6)
+ msgpack (~> 1.2)
+ bootstrap (5.3.8)
+ popper_js (>= 2.11.8, < 3)
+ builder (3.3.0)
+ byebug (13.0.0)
+ reline (>= 0.6.0)
+ castle-rb (8.1.0)
+ cgi (0.5.1)
+ concurrent-ruby (1.3.6)
+ connection_pool (3.0.2)
crass (1.0.6)
- devise (4.7.3)
+ date (3.5.1)
+ devise (5.0.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
- railties (>= 4.1.0)
+ railties (>= 7.0)
responders
warden (~> 1.2.3)
- diff-lcs (1.4.4)
- docile (1.3.4)
- erubi (1.10.0)
- execjs (2.7.0)
- factory_bot (6.1.0)
- activesupport (>= 5.0.0)
- factory_bot_rails (6.1.0)
- factory_bot (~> 6.1.0)
- railties (>= 5.0.0)
- faker (2.15.1)
- i18n (>= 1.6, < 2)
- ffi (1.14.2)
- globalid (0.4.2)
- activesupport (>= 4.2.0)
- hamlit (2.13.2)
+ diff-lcs (1.6.2)
+ docile (1.4.1)
+ dotenv (3.2.0)
+ dotenv-rails (3.2.0)
+ dotenv (= 3.2.0)
+ railties (>= 6.1)
+ drb (2.2.3)
+ erb (6.0.4)
+ erubi (1.13.1)
+ factory_bot (6.6.0)
+ activesupport (>= 6.1.0)
+ factory_bot_rails (6.5.1)
+ factory_bot (~> 6.5)
+ railties (>= 6.1.0)
+ faker (3.8.0)
+ i18n (>= 1.8.11, < 2)
+ ffi (1.17.4)
+ ffi (1.17.4-arm64-darwin)
+ globalid (1.3.0)
+ activesupport (>= 6.1)
+ hamlit (4.0.0)
temple (>= 0.8.2)
thor
tilt
- hashie (4.1.0)
- i18n (1.8.5)
+ hamlit-rails (0.2.3)
+ actionpack (>= 4.0.1)
+ activesupport (>= 4.0.1)
+ hamlit (>= 1.2.0)
+ railties (>= 4.0.1)
+ hashie (5.1.0)
+ logger
+ i18n (1.14.8)
concurrent-ruby (~> 1.0)
- jquery-rails (4.4.0)
- rails-dom-testing (>= 1, < 3)
- railties (>= 4.2.0)
- thor (>= 0.14, < 2.0)
- loofah (2.8.0)
+ io-console (0.8.2)
+ irb (1.18.0)
+ pp (>= 0.6.0)
+ prism (>= 1.3.0)
+ rdoc (>= 4.0.0)
+ reline (>= 0.4.2)
+ json (2.19.8)
+ logger (1.7.0)
+ loofah (2.25.1)
crass (~> 1.0.2)
- nokogiri (>= 1.5.9)
- mail (2.7.1)
+ nokogiri (>= 1.12.0)
+ mail (2.9.0)
+ logger
mini_mime (>= 0.1.1)
- marcel (0.3.3)
- mimemagic (~> 0.3.2)
- method_source (1.0.0)
- mimemagic (0.3.5)
- mini_mime (1.0.2)
- mini_portile2 (2.4.0)
- minitest (5.14.2)
- nio4r (2.5.4)
- nokogiri (1.10.10)
- mini_portile2 (~> 2.4.0)
- oauth (0.5.4)
- omniauth (1.9.1)
+ net-imap
+ net-pop
+ net-smtp
+ marcel (1.2.1)
+ mini_mime (1.1.5)
+ mini_portile2 (2.8.9)
+ minitest (6.0.6)
+ drb (~> 2.0)
+ prism (~> 1.5)
+ msgpack (1.8.1)
+ net-imap (0.6.4)
+ date
+ net-protocol
+ net-pop (0.1.2)
+ net-protocol
+ net-protocol (0.2.2)
+ timeout
+ net-smtp (0.5.1)
+ net-protocol
+ nio4r (2.7.5)
+ nokogiri (1.19.3)
+ mini_portile2 (~> 2.8.2)
+ racc (~> 1.4)
+ nokogiri (1.19.3-arm64-darwin)
+ racc (~> 1.4)
+ oauth (1.1.5)
+ auth-sanitizer (~> 0.1, >= 0.1.3)
+ base64 (~> 0.1)
+ cgi
+ oauth-tty (~> 1.0, >= 1.0.8)
+ snaky_hash (~> 2.0, >= 2.0.4)
+ version_gem (~> 1.1, >= 1.1.9)
+ oauth-tty (1.0.8)
+ auth-sanitizer (~> 0.1, >= 0.1.3)
+ cgi
+ version_gem (~> 1.1, >= 1.1.9)
+ omniauth (2.1.4)
hashie (>= 3.4.6)
- rack (>= 1.6.2, < 3)
- omniauth-oauth (1.1.0)
+ logger
+ rack (>= 2.2.3)
+ rack-protection
+ omniauth-oauth (1.2.1)
oauth
- omniauth (~> 1.0)
+ omniauth (>= 1.0, < 3)
+ rack (>= 1.6.2, < 4)
+ omniauth-rails_csrf_protection (2.0.1)
+ actionpack (>= 4.2)
+ omniauth (~> 2.0)
omniauth-twitter (1.4.0)
omniauth-oauth (~> 1.1)
rack
orm_adapter (0.5.0)
- popper_js (1.16.0)
- puma (5.1.1)
+ popper_js (2.11.8)
+ pp (0.6.3)
+ prettyprint
+ prettyprint (0.2.0)
+ prism (1.9.0)
+ psych (5.4.0)
+ date
+ stringio
+ puma (6.6.1)
nio4r (~> 2.0)
- rack (2.2.3)
- rack-test (1.1.0)
- rack (>= 1.0, < 3)
- rails (6.1.0)
- actioncable (= 6.1.0)
- actionmailbox (= 6.1.0)
- actionmailer (= 6.1.0)
- actionpack (= 6.1.0)
- actiontext (= 6.1.0)
- actionview (= 6.1.0)
- activejob (= 6.1.0)
- activemodel (= 6.1.0)
- activerecord (= 6.1.0)
- activestorage (= 6.1.0)
- activesupport (= 6.1.0)
+ racc (1.8.1)
+ rack (3.2.6)
+ rack-protection (4.2.1)
+ base64 (>= 0.1.0)
+ logger (>= 1.6.0)
+ rack (>= 3.0.0, < 4)
+ rack-session (2.1.2)
+ base64 (>= 0.1.0)
+ rack (>= 3.0.0)
+ rack-test (2.2.0)
+ rack (>= 1.3)
+ rackup (2.3.1)
+ rack (>= 3)
+ rails (8.1.3)
+ actioncable (= 8.1.3)
+ actionmailbox (= 8.1.3)
+ actionmailer (= 8.1.3)
+ actionpack (= 8.1.3)
+ actiontext (= 8.1.3)
+ actionview (= 8.1.3)
+ activejob (= 8.1.3)
+ activemodel (= 8.1.3)
+ activerecord (= 8.1.3)
+ activestorage (= 8.1.3)
+ activesupport (= 8.1.3)
bundler (>= 1.15.0)
- railties (= 6.1.0)
- sprockets-rails (>= 2.0.0)
+ railties (= 8.1.3)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
- rails-dom-testing (2.0.3)
- activesupport (>= 4.2.0)
+ rails-dom-testing (2.3.0)
+ activesupport (>= 5.0.0)
+ minitest
nokogiri (>= 1.6)
- rails-html-sanitizer (1.3.0)
- loofah (~> 2.3)
- rails-ujs (0.1.0)
- railties (>= 3.1)
- railties (6.1.0)
- actionpack (= 6.1.0)
- activesupport (= 6.1.0)
- method_source
- rake (>= 0.8.7)
- thor (~> 1.0)
- rake (13.0.3)
- responders (3.0.1)
- actionpack (>= 5.0)
- railties (>= 5.0)
- rspec-core (3.10.1)
- rspec-support (~> 3.10.0)
- rspec-expectations (3.10.1)
+ rails-html-sanitizer (1.7.0)
+ loofah (~> 2.25)
+ nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
+ railties (8.1.3)
+ actionpack (= 8.1.3)
+ activesupport (= 8.1.3)
+ irb (~> 1.13)
+ rackup (>= 1.0.0)
+ rake (>= 12.2)
+ thor (~> 1.0, >= 1.2.2)
+ tsort (>= 0.2)
+ zeitwerk (~> 2.6)
+ rake (13.4.2)
+ rdoc (7.2.0)
+ erb
+ psych (>= 4.0.0)
+ tsort
+ reline (0.6.3)
+ io-console (~> 0.5)
+ responders (3.2.0)
+ actionpack (>= 7.0)
+ railties (>= 7.0)
+ rspec-core (3.13.6)
+ rspec-support (~> 3.13.0)
+ rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
- rspec-support (~> 3.10.0)
- rspec-mocks (3.10.1)
+ rspec-support (~> 3.13.0)
+ rspec-mocks (3.13.8)
diff-lcs (>= 1.2.0, < 2.0)
- rspec-support (~> 3.10.0)
- rspec-rails (4.0.2)
- actionpack (>= 4.2)
- activesupport (>= 4.2)
- railties (>= 4.2)
- rspec-core (~> 3.10)
- rspec-expectations (~> 3.10)
- rspec-mocks (~> 3.10)
- rspec-support (~> 3.10)
- rspec-support (3.10.1)
- sass-rails (6.0.0)
- sassc-rails (~> 2.1, >= 2.1.1)
+ rspec-support (~> 3.13.0)
+ rspec-rails (7.1.1)
+ actionpack (>= 7.0)
+ activesupport (>= 7.0)
+ railties (>= 7.0)
+ rspec-core (~> 3.13)
+ rspec-expectations (~> 3.13)
+ rspec-mocks (~> 3.13)
+ rspec-support (~> 3.13)
+ rspec-support (3.13.7)
sassc (2.4.0)
ffi (~> 1.9)
sassc-rails (2.1.2)
@@ -198,64 +291,84 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
- simple_form (5.0.3)
- actionpack (>= 5.0)
- activemodel (>= 5.0)
- simplecov (0.20.0)
+ securerandom (0.4.1)
+ simple_form (5.4.1)
+ actionpack (>= 7.0)
+ activemodel (>= 7.0)
+ simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
- simplecov-html (0.12.3)
- simplecov_json_formatter (0.1.2)
- sprockets (4.0.2)
+ simplecov-html (0.13.2)
+ simplecov_json_formatter (0.1.4)
+ snaky_hash (2.0.4)
+ hashie (>= 0.1.0, < 6)
+ version_gem (>= 1.1.8, < 3)
+ sprockets (4.2.2)
concurrent-ruby (~> 1.0)
- rack (> 1, < 3)
- sprockets-rails (3.2.2)
- actionpack (>= 4.0)
- activesupport (>= 4.0)
+ logger
+ rack (>= 2.2.4, < 4)
+ sprockets-rails (3.5.2)
+ actionpack (>= 6.1)
+ activesupport (>= 6.1)
sprockets (>= 3.0.0)
- sqlite3 (1.4.2)
- temple (0.8.2)
- thor (1.0.1)
- tilt (2.0.10)
- tzinfo (2.0.4)
+ sqlite3 (2.9.4)
+ mini_portile2 (~> 2.8.0)
+ sqlite3 (2.9.4-arm64-darwin)
+ stringio (3.2.0)
+ temple (0.10.4)
+ thor (1.5.0)
+ tilt (2.7.0)
+ timeout (0.6.1)
+ tsort (0.2.0)
+ tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
- uglifier (4.2.0)
- execjs (>= 0.3.0, < 3)
+ uri (1.1.1)
+ useragent (0.16.11)
+ version_gem (1.1.10)
warden (1.2.9)
rack (>= 2.0.9)
- websocket-driver (0.7.3)
+ web-console (4.2.1)
+ actionview (>= 6.0.0)
+ activemodel (>= 6.0.0)
+ bindex (>= 0.4.0)
+ railties (>= 6.0.0)
+ websocket-driver (0.8.0)
+ base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
- zeitwerk (2.4.2)
+ zeitwerk (2.8.2)
PLATFORMS
+ arm64-darwin-25
ruby
DEPENDENCIES
- bootstrap
+ bootsnap
+ bootstrap (~> 5.3)
byebug
- castle-rb
- devise
+ castle-rb (~> 8.1)
+ devise (~> 5.0)
+ dotenv-rails
factory_bot_rails
faker
- hamlit
- jquery-rails
+ hamlit-rails
+ omniauth-rails_csrf_protection
omniauth-twitter
- puma
- rails
+ puma (~> 6.4)
+ rails (~> 8.1.3)
rails-controller-testing
- rails-ujs
responders
rspec-rails
- sass-rails
+ sassc-rails
simple_form
simplecov
- sqlite3
- uglifier
+ sprockets-rails
+ sqlite3 (~> 2.1)
+ web-console
RUBY VERSION
- ruby 2.7.2p137
+ ruby 3.4.9p82
BUNDLED WITH
- 2.2.3
+ 2.7.2
diff --git a/README.md b/README.md
index 26dc13e..ec18935 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,106 @@
-# rails-example-app-1
+# Castle demo application: Ruby on Rails
-Example Ruby on Rails application with Castle integration
+This project demonstrates how to integrate [Castle](https://castle.io) into a
+real Ruby on Rails application. It is built on Rails 8.1 with Devise for
+authentication and uses the [castle-rb](https://github.com/castle/castle-ruby)
+SDK (8.x).
-## Setup
+## What's demonstrated
-Run the setup script:
+- **login** – successful logins are scored with the `risk` endpoint; failed
+ logins are sent to `filter`. The returned verdict (`allow`, `challenge` or
+ `deny`) drives whether the session is allowed.
+- **logout & profile updates** – recorded with the non-blocking `log` endpoint.
+- **Twitter/X OAuth login** – the same risk assessment applied to social sign-in.
+- **webhooks** – incoming Castle webhooks are signature-verified with
+ `Castle::Webhooks::Verify` and listed in the app.
+- **browser SDK** – the `@castleio/castle-js` SDK mints a request token in the
+ browser that is submitted with the login form and forwarded to the API.
-`./bin/setup`
+## Prerequisites
-## Usage
+You'll need a Castle account. If you don't have one, start a free trial at
+https://castle.io. From the dashboard (Settings → API) you'll need:
-Run `bundle exec rails server`
+- your **publishable key** (`pk`) – used by the browser SDK
+- your **API secret** – used by the backend SDK
+
+## Running locally
+
+This app targets **Ruby 3.4** (see `.ruby-version`).
+
+Clone the repo and install dependencies:
+
+```bash
+git clone https://github.com/castle/castle-ruby-example.git
+cd castle-ruby-example
+bundle install
+```
+
+Configure your environment and database:
+
+```bash
+cp .env.example .env # then fill in CASTLE_API_SECRET and CASTLE_PK
+cp config/database.yml.example config/database.yml
+bin/rails db:prepare
+```
+
+Run the app:
+
+```bash
+bin/rails server
+# => http://127.0.0.1:3000
+```
+
+`bin/setup` runs the dependency install, file copying and database setup in one
+step.
+
+## Configuration
+
+All configuration is read from environment variables (loaded from `.env` in
+development and test via `dotenv-rails`):
+
+| Variable | Purpose |
+| -------------------- | ---------------------------------------------------- |
+| `CASTLE_API_SECRET` | Server-side API secret used by the `castle-rb` SDK. |
+| `CASTLE_PK` | Publishable key used by the browser SDK. |
+| `TWITTER_APP_ID` | Optional – enables the Twitter/X OAuth login button. |
+| `TWITTER_SECRET` | Optional – Twitter/X OAuth secret. |
+| `SECRET_KEY_BASE` | Required in production only. |
+
+## Running the tests
+
+```bash
+bundle exec rspec
+```
+
+## Running with Docker
+
+The bundled `Dockerfile` is a multi-stage build that compiles assets and runs
+the app with Puma as an unprivileged user on port 3000. The SQLite database is
+created on first boot.
+
+Build the image:
+
+```bash
+docker build -t castle-demo-ruby .
+```
+
+Run a container, passing your Castle credentials:
+
+```bash
+docker run -d -p 4006:3000 \
+ -e CASTLE_API_SECRET=YOUR_API_SECRET \
+ -e CASTLE_PK=YOUR_PUBLISHABLE_KEY \
+ castle-demo-ruby
+```
+
+The app will be available at http://127.0.0.1:4006. A `SECRET_KEY_BASE` is
+generated automatically if you don't supply one (set it explicitly to keep
+sessions across restarts).
+
+## Disclaimer
+
+This sample app is shared in the hope that other developers find it useful.
+Although it is not an officially supported sample, we welcome questions and
+suggestions at `support@castle.io`.
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index 5656acb..303dc79 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -10,7 +10,7 @@
// Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details
// about supported directives.
//
-//= require jquery
//= require rails-ujs
-//= require bootstrap-sprockets
+//= require popper
+//= require bootstrap
//= require_tree .
diff --git a/app/assets/javascripts/castle.js b/app/assets/javascripts/castle.js
new file mode 100644
index 0000000..d551705
--- /dev/null
+++ b/app/assets/javascripts/castle.js
@@ -0,0 +1,54 @@
+// Castle browser SDK glue.
+//
+// The SDK itself (and `Castle.configure`) is loaded in the layout, only when a
+// publishable key is configured. Here we make sure that any form opting in with
+// `data-castle="true"` carries a fresh request token in a hidden
+// `castle_request_token` field, which the backend forwards to the risk/filter
+// endpoints.
+(function () {
+ function setToken(form, token) {
+ var field = form.querySelector('input[name="castle_request_token"]');
+ if (!field) {
+ field = document.createElement("input");
+ field.type = "hidden";
+ field.name = "castle_request_token";
+ form.appendChild(field);
+ }
+ field.value = token || "";
+ }
+
+ function attach(form) {
+ var submitted = false;
+
+ form.addEventListener("submit", function (event) {
+ var sdkReady = window.Castle && typeof Castle.createRequestToken === "function";
+ if (submitted || !sdkReady) {
+ return; // already handled, or no SDK configured — submit as-is
+ }
+
+ event.preventDefault();
+
+ Castle.createRequestToken()
+ .then(function (token) {
+ setToken(form, token);
+ })
+ .catch(function (err) {
+ console.error("Castle.createRequestToken failed", err);
+ setToken(form, "");
+ })
+ .then(function () {
+ submitted = true;
+ if (typeof form.requestSubmit === "function") {
+ form.requestSubmit();
+ } else {
+ form.submit();
+ }
+ });
+ });
+ }
+
+ document.addEventListener("DOMContentLoaded", function () {
+ var forms = document.querySelectorAll('form[data-castle="true"]');
+ Array.prototype.forEach.call(forms, attach);
+ });
+})();
diff --git a/app/assets/stylesheets/dashboard.css.scss b/app/assets/stylesheets/dashboard.css.scss
index 14d62f6..d20ceae 100644
--- a/app/assets/stylesheets/dashboard.css.scss
+++ b/app/assets/stylesheets/dashboard.css.scss
@@ -68,15 +68,7 @@ body {
}
[role="main"] {
- padding-top: 48px; /* Space for fixed navbar */
-}
-
-.navbar-brand {
- padding-top: .75rem;
- padding-bottom: .75rem;
- font-size: 1rem;
- background-color: rgba(0, 0, 0, .25);
- box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25);
+ padding-top: 5rem; /* Space for fixed navbar */
}
.navbar .form-control {
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index f8e45d2..efc6523 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -11,4 +11,14 @@ class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
before_action :authenticate_user!
+
+ private
+
+ # The request token is minted client-side by the Castle browser SDK and
+ # submitted in a hidden field. It ties the browser fingerprint to the
+ # server-side risk/filter call.
+ # @return [String, nil]
+ def castle_request_token
+ params[:castle_request_token]
+ end
end
diff --git a/app/controllers/integrations/castle_webhooks_controller.rb b/app/controllers/integrations/castle_webhooks_controller.rb
index 6aab2dd..d7e9a5c 100644
--- a/app/controllers/integrations/castle_webhooks_controller.rb
+++ b/app/controllers/integrations/castle_webhooks_controller.rb
@@ -39,18 +39,16 @@ def request_body
end
end
- # Verifies that the incoming request comes from Castle
+ # Verifies that the incoming request comes from Castle by checking the
+ # X-Castle-Signature header against the raw body using the SDK helper.
# @note We trigger ActionController::RoutingError to notify any invalid request sender that
# an endpoint like that does not exist
# @raise [ActionController::RoutingError] routing error if it was not castle request
def verify_request
- return if Integrations::CastleWebhookVerifier.valid?(
- request_body,
- # We have to cast to string, in case it is nil. If signature is nil, it means
- # that something is not right and the verifier expects string
- request.headers['X-Castle-Signature'].to_s
- )
+ raise ActionController::RoutingError, 'Not found' if request.headers['X-Castle-Signature'].blank?
+ Castle::Webhooks::Verify.call(request)
+ rescue Castle::Error
raise ActionController::RoutingError, 'Not found'
end
end
diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb
index a68dcae..b9f53a5 100644
--- a/app/controllers/users/omniauth_callbacks_controller.rb
+++ b/app/controllers/users/omniauth_callbacks_controller.rb
@@ -15,7 +15,7 @@ def twitter
else
flash[:error] = t('.error')
redirect_to new_user_registration_url
- castle.track(event: '$login.failed', user_id: current_user&.id)
+ report_failed_login(current_user)
end
end
@@ -24,15 +24,12 @@ def twitter
# Checks if user can be authenticated and if so user will be signed in.
# @param current_user [User] user that we want to authenticate
def authenticate(current_user)
- case authenticate_with_castle(current_user)[:action]
- when 'allow'
- sign_in_with_notice(current_user)
- when 'challenge'
- sign_in_with_notice(current_user)
- when 'deny'
+ if evaluate_login(current_user) == 'deny'
warden.logout
flash[:error] = t('.access_denied')
redirect_to new_user_session_url
+ else
+ sign_in_with_notice(current_user)
end
end
@@ -43,15 +40,31 @@ def sign_in_with_notice(current_user)
set_flash_message(:notice, :success, kind: 'Twitter') if is_navigational_format?
end
- # Authenticates user in Castle
- # @param current_user [User]
- # @return [Hash] verdict details
- def authenticate_with_castle(current_user)
- castle.authenticate(
- event: '$login.succeeded',
- user_id: current_user.id,
- user_traits: current_user.attributes
- ).freeze
+ # Sends a successful OAuth login to the risk endpoint and returns the verdict.
+ # @param user [User]
+ # @return [String] the Castle policy action: 'allow', 'challenge' or 'deny'
+ def evaluate_login(user)
+ castle.risk(
+ type: '$login',
+ status: '$succeeded',
+ request_token: castle_request_token,
+ user: { id: user.id, email: user.email }
+ ).dig(:policy, :action)
+ rescue Castle::Error
+ 'allow'
+ end
+
+ # Reports a failed OAuth login to the filter endpoint.
+ # @param user [User]
+ def report_failed_login(user)
+ castle.filter(
+ type: '$login',
+ status: '$failed',
+ request_token: castle_request_token,
+ user: { id: user&.id }
+ )
+ rescue Castle::Error
+ nil
end
end
end
diff --git a/app/controllers/users/profiles_controller.rb b/app/controllers/users/profiles_controller.rb
index ae42f74..2f7d0ee 100644
--- a/app/controllers/users/profiles_controller.rb
+++ b/app/controllers/users/profiles_controller.rb
@@ -20,16 +20,18 @@ def user_params
params.require(:user).permit(:email)
end
- # After action for tracking user profile update with details on whether
- # it was a successful change or not
+ # After action that logs the profile update to Castle with the non-blocking
+ # log endpoint, noting whether the change was valid.
def track_profile_update
- event = current_user.valid? ? 'succeeded' : 'failed'
+ status = current_user.valid? ? '$succeeded' : '$failed'
- castle.track(
- event: "$profile_update.#{event}",
- user_id: current_user.id,
- user_traits: current_user.attributes
+ castle.log(
+ type: '$profile_update',
+ status: status,
+ user: { id: current_user.id, email: current_user.email }
)
+ rescue Castle::Error
+ nil
end
end
end
diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb
index 26ba185..5be6e0a 100644
--- a/app/controllers/users/sessions_controller.rb
+++ b/app/controllers/users/sessions_controller.rb
@@ -8,19 +8,17 @@ class SessionsController < Devise::SessionsController
# Key that is used in Devise for user authentication
AUTHENTICATION_KEY = 'email'
- # Sign in with Castle tracking.
- # @note For now we allow user when verdict is not deny. Challenge could be implemented
+ # Sign in with Castle risk assessment.
+ # @note A 'challenge' verdict is treated as 'allow' here; a real app would
+ # step up to MFA. 'deny' blocks the login.
def create
if warden.authenticate(auth_options)
- case authenticate_with_castle(current_user)[:action]
- when 'allow'
- super
- when 'challenge'
- super
- when 'deny'
+ if evaluate_login(current_user) == 'deny'
warden.logout
flash[:error] = t('.access_denied')
redirect_to new_user_session_url
+ else
+ super
end
else
track_failed_login
@@ -28,34 +26,52 @@ def create
end
end
- # Sign out with Castle tracking
+ # Sign out, logged to Castle with the non-blocking log endpoint.
def destroy
# This is a failover just in case there is no user because an unauthenticated user
# tried to logout
user_id = current_user&.id
super
- castle.track(event: '$logout.succeeded', user_id: user_id)
+ castle.log(
+ type: '$logout',
+ status: '$succeeded',
+ user: { id: user_id }
+ )
end
private
# Takes the request form data (login and password) and tries to find the user for which the
- # authentication process failed and tracks a failed login
+ # authentication process failed and reports a failed login to the filter endpoint.
def track_failed_login
user_params = params.fetch('user') { {} }.except(*Rails.application.config.filter_parameters)
- user = User.find_by(AUTHENTICATION_KEY => user_params[AUTHENTICATION_KEY])
- castle.track(event: '$login.failed', user_id: user&.id, user_traits: user_params)
+ email = user_params[AUTHENTICATION_KEY]
+ user = User.find_by(AUTHENTICATION_KEY => email)
+
+ castle.filter(
+ type: '$login',
+ status: '$failed',
+ request_token: castle_request_token,
+ user: { id: user&.id, email: email },
+ params: { email: email }
+ )
+ rescue Castle::Error
+ nil
end
- # Authenticates user in Castle
- # @param current_user [User]
- # @return [Hash] verdict details
- def authenticate_with_castle(current_user)
- castle.authenticate(
- event: '$login.succeeded',
- user_id: current_user.id,
- user_traits: current_user.attributes
- ).freeze
+ # Sends a successful login to the risk endpoint and returns the verdict.
+ # @param user [User]
+ # @return [String] the Castle policy action: 'allow', 'challenge' or 'deny'
+ def evaluate_login(user)
+ castle.risk(
+ type: '$login',
+ status: '$succeeded',
+ request_token: castle_request_token,
+ user: { id: user.id, email: user.email }
+ ).dig(:policy, :action)
+ rescue Castle::Error
+ # Never lock a user out because Castle is unhappy with the request.
+ 'allow'
end
end
end
diff --git a/app/models/integrations/castle_webhook.rb b/app/models/integrations/castle_webhook.rb
index 881f72d..2df6486 100644
--- a/app/models/integrations/castle_webhook.rb
+++ b/app/models/integrations/castle_webhook.rb
@@ -5,7 +5,7 @@ module Integrations
class CastleWebhook < ApplicationRecord
self.table_name = :integrations_castle_webhooks
- serialize :body
+ serialize :body, coder: JSON
scope :recent, -> { order(created_at: :desc).limit(50) }
end
diff --git a/app/views/layouts/_castle_js.html.erb b/app/views/layouts/_castle_js.html.erb
index 3c68ef5..99bcc46 100644
--- a/app/views/layouts/_castle_js.html.erb
+++ b/app/views/layouts/_castle_js.html.erb
@@ -1,9 +1,10 @@
-
+<% if ENV["CASTLE_PK"].present? %>
+ <%# Castle browser SDK. Mints the request token that ties the browser to the %>
+ <%# server-side risk/filter calls. See app/assets/javascripts/castle.js. %>
+
+
+<% end %>
diff --git a/app/views/layouts/_messages.html.haml b/app/views/layouts/_messages.html.haml
index c1aee2d..066557b 100644
--- a/app/views/layouts/_messages.html.haml
+++ b/app/views/layouts/_messages.html.haml
@@ -1,5 +1,5 @@
- flash.each do |name, msg|
- if msg.is_a?(String)
- %div{ class: "alert alert-#{name.to_s == 'notice' ? 'success' : 'danger'}", role: 'alert' }
- %button.close{ 'data-dismiss' => 'alert', type: 'button' } ×
- = content_tag :div, msg, id: "flash_#{name}"
+ %div{ class: "alert alert-#{name.to_s == 'notice' ? 'success' : 'danger'} alert-dismissible fade show", role: 'alert' }
+ = content_tag :span, msg, id: "flash_#{name}"
+ %button.btn-close{ type: 'button', 'data-bs-dismiss' => 'alert', 'aria-label' => 'Close' }
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index fdcc5f8..f825484 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -1,47 +1,38 @@
!!!
-%html
+%html{ lang: 'en' }
= render 'layouts/header'
%body
- %nav.navbar.navbar-expand-lg.navbar-dark.fixed-top.bg-dark.flex-md-nowrap.p-0
- %a.navbar-brand.col-sm-4.col-md-3.col-lg-2.mr-0#logo{ href: root_path }
- = image_tag('logo.png')
+ %nav.navbar.navbar-expand-lg.fixed-top.bg-dark.shadow-sm{ 'data-bs-theme' => 'dark' }
+ .container-fluid
+ = link_to image_tag('logo.png', height: 28, alt: 'Castle'), root_path, class: 'navbar-brand', id: 'logo'
- %ul.navbar-nav.px-3
- %li.nav-item.mr-5
- = link_to t('.nav.webhooks'),
- integrations_castle_webhooks_path,
- class: 'nav-link'
+ %button.navbar-toggler{ type: 'button', 'data-bs-toggle' => 'collapse',
+ 'data-bs-target' => '#navbar-nav', 'aria-controls' => 'navbar-nav',
+ 'aria-expanded' => 'false', 'aria-label' => 'Toggle navigation' }
+ %span.navbar-toggler-icon
- - if user_signed_in?
- %li.nav-item
- = link_to t('.nav.edit_password'),
- edit_user_registration_path,
- class: 'nav-link'
- %li.nav-item
- = link_to t('.nav.edit_profile'),
- edit_users_profile_path,
- class: 'nav-link'
- %li.nav-item
- = link_to t('.nav.sign_out'),
- destroy_user_session_path,
- method: :delete,
- class: 'nav-link'
- - else
- %li.nav-item
- = link_to t('.nav.login'),
- new_user_session_path,
- class: 'nav-link'
- %li.nav-item
- = link_to t('.nav.register'),
- new_user_registration_path,
- class: 'nav-link'
+ #navbar-nav.collapse.navbar-collapse
+ %ul.navbar-nav.ms-auto
+ %li.nav-item
+ = link_to t('.nav.webhooks'), integrations_castle_webhooks_path, class: 'nav-link'
- .container-fluid
- .row
- %main.col-md-9.ml-sm-auto.col-lg-10.px-4{ role: 'main' }
- = render 'layouts/messages'
+ - if user_signed_in?
+ %li.nav-item
+ = link_to t('.nav.edit_password'), edit_user_registration_path, class: 'nav-link'
+ %li.nav-item
+ = link_to t('.nav.edit_profile'), edit_users_profile_path, class: 'nav-link'
+ %li.nav-item
+ = link_to t('.nav.sign_out'), destroy_user_session_path, method: :delete, class: 'nav-link'
+ - else
+ %li.nav-item
+ = link_to t('.nav.login'), new_user_session_path, class: 'nav-link'
+ %li.nav-item
+ = link_to t('.nav.register'), new_user_registration_path, class: 'nav-link'
- = yield
+ %main.container{ role: 'main' }
+ = render 'layouts/messages'
+
+ = yield
= render 'layouts/castle_js'
diff --git a/app/views/main/index.html.haml b/app/views/main/index.html.haml
index c2c2d90..2834074 100644
--- a/app/views/main/index.html.haml
+++ b/app/views/main/index.html.haml
@@ -1,29 +1,51 @@
-.jumbotron
- %h1.display-4 Castle Rails example app
+.p-5.mb-4.bg-body-tertiary.rounded-3
+ .container-fluid.py-3
+ %h1.display-5.fw-bold Castle Rails example app
+ %p.col-md-10.fs-5
+ A minimal Ruby on Rails + Devise application showing how to wire the
+ = succeed '' do
+ %a{ href: 'https://github.com/castle/castle-ruby' } castle-rb
+ SDK into a real authentication flow.
.d-flex.justify-content-between.flex-wrap.align-items-center.pt-3.pb-2.mb-3.border-bottom
- %h2.h2 About Castle.io Example App
+ %h2.h3 What's demonstrated
-%p.lead
- This app was made to illustrate how Castle.io can be integrated within a Ruby on Rails
- application. It includes:
-
- %ul
- %li
- User model with Devise authentication
- %li
- Profile update
- %li
- Password change
- %li
- Twitter OAuth signup
+%ul
+ %li
+ %strong Login
+ — successful logins are scored with the
+ %code risk
+ endpoint; failed logins go to
+ %code filter
+ and the verdict can allow, challenge or deny the user.
+ %li
+ %strong Logout & profile updates
+ — recorded with the non-blocking
+ %code log
+ endpoint.
+ %li
+ %strong Twitter/X OAuth login
+ — the same risk assessment applied to social sign-in.
+ %li
+ %strong Webhooks
+ — incoming Castle webhooks are signature-verified and listed under
+ = link_to 'Webhooks', integrations_castle_webhooks_path
.d-flex.justify-content-between.flex-wrap.align-items-center.pt-3.pb-2.mb-3.border-bottom
- %h2.h2 Details
+ %h2.h3 Configuration
%p
- In order to make Twitter OAuth work you need to:
-
- %ul
- %li Create an OAuth Twitter App
- %li Make sure, that you configure config/secrets.yml properly.
+ Copy
+ %code.env.example
+ to
+ %code.env
+ and fill in your Castle credentials
+ (CASTLE_API_SECRET and CASTLE_PK) from the
+ = succeed '.' do
+ %a{ href: 'https://dashboard.castle.io' } Castle dashboard
+%p
+ Twitter/X OAuth is optional: set
+ %code TWITTER_APP_ID
+ and
+ %code TWITTER_SECRET
+ to enable the social login button.
diff --git a/app/views/users/sessions/new.html.haml b/app/views/users/sessions/new.html.haml
index 34f8f3b..1eb0a0a 100644
--- a/app/views/users/sessions/new.html.haml
+++ b/app/views/users/sessions/new.html.haml
@@ -1,6 +1,6 @@
%h2.mb-4= t('.title')
-= simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f|
+= simple_form_for(resource, as: resource_name, url: session_path(resource_name), html: { data: { castle: true } }) do |f|
.form-inputs
= f.input :email, required: false, autofocus: true
= f.input :password, required: false
diff --git a/app/views/users/shared/_links.html.haml b/app/views/users/shared/_links.html.haml
index f61be36..ac3705b 100644
--- a/app/views/users/shared/_links.html.haml
+++ b/app/views/users/shared/_links.html.haml
@@ -9,8 +9,9 @@
class: 'btn btn-alt'
- if devise_mapping.omniauthable?
- resource_class.omniauth_providers.each do |provider|
- = link_to t('.sign_in_with', name: OmniAuth::Utils.camelize(provider)),
- omniauth_authorize_path(resource_name,
- provider,
- callback_url: user_twitter_omniauth_callback_url),
- class: 'btn btn-alt'
+ = button_to t('.sign_in_with', name: OmniAuth::Utils.camelize(provider)),
+ omniauth_authorize_path(resource_name,
+ provider,
+ callback_url: user_twitter_omniauth_callback_url),
+ method: :post,
+ class: 'btn btn-alt'
diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint
new file mode 100755
index 0000000..dc0587d
--- /dev/null
+++ b/bin/docker-entrypoint
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+set -e
+
+# Generate an ephemeral SECRET_KEY_BASE if one wasn't supplied. Good enough for
+# a throwaway demo container; set it explicitly to keep sessions across restarts.
+if [ -z "${SECRET_KEY_BASE}" ]; then
+ export SECRET_KEY_BASE="$(ruby -rsecurerandom -e 'print SecureRandom.hex(64)')"
+fi
+
+# Create and migrate the SQLite database on first boot.
+bundle exec rails db:prepare
+
+exec "$@"
diff --git a/bin/setup b/bin/setup
index 398bd43..6533d0f 100755
--- a/bin/setup
+++ b/bin/setup
@@ -25,8 +25,9 @@ chdir APP_ROOT do
cp 'config/database.yml.example', 'config/database.yml'
end
- unless File.exist?('config/secrets.yml')
- cp 'config/secrets.yml.example', 'config/secrets.yml'
+ unless File.exist?('.env')
+ cp '.env.example', '.env'
+ puts ' Fill in your Castle credentials in .env (CASTLE_API_SECRET, CASTLE_PK).'
end
puts "\n== Preparing database =="
diff --git a/config/application.rb b/config/application.rb
index 4d9d106..3551646 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -12,8 +12,13 @@
module CastleExample
# Rails app
class Application < Rails::Application
- config.load_defaults 5.2
+ config.load_defaults 8.1
+
+ # This example app doesn't ship encrypted credentials. Outside production we
+ # read the secret from the environment (with a static fallback) so boot never
+ # depends on a master key; production still requires SECRET_KEY_BASE.
+ unless Rails.env.production?
+ config.secret_key_base = ENV.fetch('SECRET_KEY_BASE', 'castle_example_dev_test_secret_key_base')
+ end
end
end
-
-require 'integrations/castle_webhook_verifier'
diff --git a/config/boot.rb b/config/boot.rb
index 30e594e..c04863f 100644
--- a/config/boot.rb
+++ b/config/boot.rb
@@ -3,3 +3,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/config/environments/development.rb b/config/environments/development.rb
index ba2b6e3..89555eb 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -25,5 +25,5 @@
config.assets.debug = true
config.assets.quiet = true
config.file_watcher = ActiveSupport::FileUpdateChecker
- config.action_view.raise_on_missing_translations = true
+ config.i18n.raise_on_missing_translations = true
end
diff --git a/config/environments/production.rb b/config/environments/production.rb
index a2cfe3e..7a14f91 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -5,9 +5,7 @@
config.eager_load = true
config.consider_all_requests_local = false
config.action_controller.perform_caching = true
- config.read_encrypted_secrets = true
config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
- config.assets.js_compressor = :uglifier
config.assets.compile = false
config.log_level = :debug
config.log_tags = %i[request_id]
diff --git a/config/environments/test.rb b/config/environments/test.rb
index 8597573..2c8656d 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -14,5 +14,5 @@
config.action_mailer.perform_caching = false
config.action_mailer.delivery_method = :test
config.active_support.deprecation = :stderr
- config.action_view.raise_on_missing_translations = true
+ config.i18n.raise_on_missing_translations = true
end
diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb
index 22ea671..ded543d 100644
--- a/config/initializers/assets.rb
+++ b/config/initializers/assets.rb
@@ -1,4 +1,3 @@
# frozen_string_literal: true
Rails.application.config.assets.version = '1.0'
-Rails.application.config.assets.paths << Rails.root.join('node_modules')
diff --git a/config/initializers/castle.rb b/config/initializers/castle.rb
index 687cda1..1dc84ae 100644
--- a/config/initializers/castle.rb
+++ b/config/initializers/castle.rb
@@ -1,3 +1,11 @@
# frozen_string_literal: true
-Castle.api_secret = Rails.application.secrets.castle_secret
+# The API secret authenticates server-to-server calls to Castle. Grab yours
+# from the Castle dashboard (Settings -> API) and expose it as an env var.
+Castle.configure do |config|
+ config.api_secret = ENV.fetch('CASTLE_API_SECRET', '')
+
+ # When Castle is unreachable or returns a 5xx, allow the request through
+ # rather than locking users out. Other options: :deny, :challenge, :throw.
+ config.failover_strategy = :allow
+end
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
index 39008cd..315f718 100644
--- a/config/initializers/devise.rb
+++ b/config/initializers/devise.rb
@@ -15,7 +15,7 @@
config.sign_out_via = :delete
config.omniauth(
:twitter,
- Rails.application.secrets.twitter_app_id,
- Rails.application.secrets.twitter_secret
+ ENV.fetch('TWITTER_APP_ID', ''),
+ ENV.fetch('TWITTER_SECRET', '')
)
end
diff --git a/config/initializers/simple_form.rb b/config/initializers/simple_form.rb
new file mode 100644
index 0000000..d268784
--- /dev/null
+++ b/config/initializers/simple_form.rb
@@ -0,0 +1,176 @@
+# frozen_string_literal: true
+#
+# Uncomment this and change the path if necessary to include your own
+# components.
+# See https://github.com/heartcombo/simple_form#custom-components to know
+# more about custom components.
+# Dir[Rails.root.join('lib/components/**/*.rb')].each { |f| require f }
+#
+# Use this setup block to configure all options available in SimpleForm.
+SimpleForm.setup do |config|
+ # Wrappers are used by the form builder to generate a
+ # complete input. You can remove any component from the
+ # wrapper, change the order or even add your own to the
+ # stack. The options given below are used to wrap the
+ # whole input.
+ config.wrappers :default, class: :input,
+ hint_class: :field_with_hint, error_class: :field_with_errors, valid_class: :field_without_errors do |b|
+ ## Extensions enabled by default
+ # Any of these extensions can be disabled for a
+ # given input by passing: `f.input EXTENSION_NAME => false`.
+ # You can make any of these extensions optional by
+ # renaming `b.use` to `b.optional`.
+
+ # Determines whether to use HTML5 (:email, :url, ...)
+ # and required attributes
+ b.use :html5
+
+ # Calculates placeholders automatically from I18n
+ # You can also pass a string as f.input placeholder: "Placeholder"
+ b.use :placeholder
+
+ ## Optional extensions
+ # They are disabled unless you pass `f.input EXTENSION_NAME => true`
+ # to the input. If so, they will retrieve the values from the model
+ # if any exists. If you want to enable any of those
+ # extensions by default, you can change `b.optional` to `b.use`.
+
+ # Calculates maxlength from length validations for string inputs
+ # and/or database column lengths
+ b.optional :maxlength
+
+ # Calculate minlength from length validations for string inputs
+ b.optional :minlength
+
+ # Calculates pattern from format validations for string inputs
+ b.optional :pattern
+
+ # Calculates min and max from length validations for numeric inputs
+ b.optional :min_max
+
+ # Calculates readonly automatically from readonly attributes
+ b.optional :readonly
+
+ ## Inputs
+ # b.use :input, class: 'input', error_class: 'is-invalid', valid_class: 'is-valid'
+ b.use :label_input
+ b.use :hint, wrap_with: { tag: :span, class: :hint }
+ b.use :error, wrap_with: { tag: :span, class: :error }
+
+ ## full_messages_for
+ # If you want to display the full error message for the attribute, you can
+ # use the component :full_error, like:
+ #
+ # b.use :full_error, wrap_with: { tag: :span, class: :error }
+ end
+
+ # The default wrapper to be used by the FormBuilder.
+ config.default_wrapper = :default
+
+ # Define the way to render check boxes / radio buttons with labels.
+ # Defaults to :nested for bootstrap config.
+ # inline: input + label
+ # nested: label > input
+ config.boolean_style = :nested
+
+ # Default class for buttons
+ config.button_class = 'btn'
+
+ # Method used to tidy up errors. Specify any Rails Array method.
+ # :first lists the first message for each field.
+ # Use :to_sentence to list all errors for each field.
+ # config.error_method = :first
+
+ # Default tag used for error notification helper.
+ config.error_notification_tag = :div
+
+ # CSS class to add for error notification helper.
+ config.error_notification_class = 'error_notification'
+
+ # Series of attempts to detect a default label method for collection.
+ # config.collection_label_methods = [ :to_label, :name, :title, :to_s ]
+
+ # Series of attempts to detect a default value method for collection.
+ # config.collection_value_methods = [ :id, :to_s ]
+
+ # You can wrap a collection of radio/check boxes in a pre-defined tag, defaulting to none.
+ # config.collection_wrapper_tag = nil
+
+ # You can define the class to use on all collection wrappers. Defaulting to none.
+ # config.collection_wrapper_class = nil
+
+ # You can wrap each item in a collection of radio/check boxes with a tag,
+ # defaulting to :span.
+ # config.item_wrapper_tag = :span
+
+ # You can define a class to use in all item wrappers. Defaulting to none.
+ # config.item_wrapper_class = nil
+
+ # How the label text should be generated altogether with the required text.
+ # config.label_text = lambda { |label, required, explicit_label| "#{required} #{label}" }
+
+ # You can define the class to use on all labels. Default is nil.
+ # config.label_class = nil
+
+ # You can define the default class to be used on forms. Can be overridden
+ # with `html: { :class }`. Defaulting to none.
+ # config.default_form_class = nil
+
+ # You can define which elements should obtain additional classes
+ # config.generate_additional_classes_for = [:wrapper, :label, :input]
+
+ # Whether attributes are required by default (or not). Default is true.
+ # config.required_by_default = true
+
+ # Tell browsers whether to use the native HTML5 validations (novalidate form option).
+ # These validations are enabled in SimpleForm's internal config but disabled by default
+ # in this configuration, which is recommended due to some quirks from different browsers.
+ # To stop SimpleForm from generating the novalidate option, enabling the HTML5 validations,
+ # change this configuration to true.
+ config.browser_validations = false
+
+ # Custom mappings for input types. This should be a hash containing a regexp
+ # to match as key, and the input type that will be used when the field name
+ # matches the regexp as value.
+ # config.input_mappings = { /count/ => :integer }
+
+ # Custom wrappers for input types. This should be a hash containing an input
+ # type as key and the wrapper that will be used for all inputs with specified type.
+ # config.wrapper_mappings = { string: :prepend }
+
+ # Namespaces where SimpleForm should look for custom input classes that
+ # override default inputs.
+ # config.custom_inputs_namespaces << "CustomInputs"
+
+ # Default priority for time_zone inputs.
+ # config.time_zone_priority = nil
+
+ # Default priority for country inputs.
+ # config.country_priority = nil
+
+ # When false, do not use translations for labels.
+ # config.translate_labels = true
+
+ # Automatically discover new inputs in Rails' autoload path.
+ # config.inputs_discovery = true
+
+ # Cache SimpleForm inputs discovery
+ # config.cache_discovery = !Rails.env.development?
+
+ # Default class for inputs
+ # config.input_class = nil
+
+ # Define the default class of the input wrapper of the boolean input.
+ config.boolean_label_class = 'checkbox'
+
+ # Defines if the default input wrapper class should be included in radio
+ # collection wrappers.
+ # config.include_default_input_wrapper_class = true
+
+ # Defines which i18n scope will be used in Simple Form.
+ # config.i18n_scope = 'simple_form'
+
+ # Defines validation classes to the input_field. By default it's nil.
+ # config.input_field_valid_class = 'is-valid'
+ # config.input_field_error_class = 'is-invalid'
+end
diff --git a/config/initializers/simple_form_bootstrap.rb b/config/initializers/simple_form_bootstrap.rb
index a4b73d2..7ec2ec6 100644
--- a/config/initializers/simple_form_bootstrap.rb
+++ b/config/initializers/simple_form_bootstrap.rb
@@ -1,24 +1,53 @@
# frozen_string_literal: true
+# These defaults are defined and maintained by the community at
+# https://github.com/heartcombo/simple_form-bootstrap
+# Please submit feedback, changes and tests only there.
+
+# Uncomment this and change the path if necessary to include your own
+# components.
+# See https://github.com/heartcombo/simple_form#custom-components
+# to know more about custom components.
+# Dir[Rails.root.join('lib/components/**/*.rb')].each { |f| require f }
+
+# Use this setup block to configure all options available in SimpleForm.
SimpleForm.setup do |config|
+ # Default class for buttons
config.button_class = 'btn'
+
+ # Define the default class of the input wrapper of the boolean input.
config.boolean_label_class = 'form-check-label'
- config.label_text = ->(label, required, _) { "#{label} #{required}" }
+
+ # How the label text should be generated altogether with the required text.
+ config.label_text = lambda { |label, required, explicit_label| "#{label} #{required}" }
+
+ # Define the way to render check boxes / radio buttons with labels.
config.boolean_style = :inline
+
+ # You can wrap each item in a collection of radio/check boxes with a tag
config.item_wrapper_tag = :div
+
+ # Defines if the default input wrapper class should be included in radio
+ # collection wrappers.
config.include_default_input_wrapper_class = false
+
+ # CSS class to add for error notification helper.
config.error_notification_class = 'alert alert-danger'
+
+ # Method used to tidy up errors. Specify any Rails Array method.
+ # :first lists the first message for each field.
+ # :to_sentence to list all errors for each field.
config.error_method = :to_sentence
+
+ # add validation classes to `input_field`
config.input_field_error_class = 'is-invalid'
config.input_field_valid_class = 'is-valid'
- config.wrappers(
- :vertical_form,
- tag: 'div',
- class: 'form-group',
- error_class: 'form-group-invalid',
- valid_class: 'form-group-valid'
- ) do |b|
+
+ # vertical forms
+ #
+ # vertical default_wrapper
+ config.wrappers :vertical_form, class: 'mb-3' do |b|
b.use :html5
b.use :placeholder
b.optional :maxlength
@@ -26,127 +55,100 @@
b.optional :pattern
b.optional :min_max
b.optional :readonly
- b.use :label, class: 'form-control-label'
+ b.use :label, class: 'form-label'
b.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback' }
- b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
+ b.use :full_error, wrap_with: { class: 'invalid-feedback' }
+ b.use :hint, wrap_with: { class: 'form-text' }
end
- config.wrappers(
- :vertical_boolean,
- tag: 'fieldset',
- class: 'form-group',
- error_class: 'form-group-invalid',
- valid_class: 'form-group-valid'
- ) do |b|
+ # vertical input for boolean
+ config.wrappers :vertical_boolean, tag: 'fieldset', class: 'mb-3' do |b|
b.use :html5
b.optional :readonly
- b.wrapper :form_check_wrapper, tag: 'div', class: 'form-check' do |bb|
+ b.wrapper :form_check_wrapper, class: 'form-check' do |bb|
bb.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid'
bb.use :label, class: 'form-check-label'
- bb.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback' }
- bb.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
+ bb.use :full_error, wrap_with: { class: 'invalid-feedback' }
+ bb.use :hint, wrap_with: { class: 'form-text' }
end
end
- config.wrappers(
- :vertical_collection,
- item_wrapper_class: 'form-check',
- tag: 'fieldset',
- class: 'form-group',
- error_class: 'form-group-invalid',
- valid_class: 'form-group-valid'
- ) do |b|
+ # vertical input for radio buttons and check boxes
+ config.wrappers :vertical_collection, item_wrapper_class: 'form-check', item_label_class: 'form-check-label', tag: 'fieldset', class: 'mb-3' do |b|
b.use :html5
b.optional :readonly
b.wrapper :legend_tag, tag: 'legend', class: 'col-form-label pt-0' do |ba|
ba.use :label_text
end
b.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' }
- b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
+ b.use :full_error, wrap_with: { class: 'invalid-feedback d-block' }
+ b.use :hint, wrap_with: { class: 'form-text' }
end
- config.wrappers(
- :vertical_collection_inline,
- item_wrapper_class: 'form-check form-check-inline',
- tag: 'fieldset',
- class: 'form-group',
- error_class: 'form-group-invalid',
- valid_class: 'form-group-valid'
- ) do |b|
+ # vertical input for inline radio buttons and check boxes
+ config.wrappers :vertical_collection_inline, item_wrapper_class: 'form-check form-check-inline', item_label_class: 'form-check-label', tag: 'fieldset', class: 'mb-3' do |b|
b.use :html5
b.optional :readonly
b.wrapper :legend_tag, tag: 'legend', class: 'col-form-label pt-0' do |ba|
ba.use :label_text
end
b.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' }
- b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
+ b.use :full_error, wrap_with: { class: 'invalid-feedback d-block' }
+ b.use :hint, wrap_with: { class: 'form-text' }
end
- config.wrappers(
- :vertical_file,
- tag: 'div',
- class: 'form-group',
- error_class: 'form-group-invalid',
- valid_class: 'form-group-valid'
- ) do |b|
+ # vertical file input
+ config.wrappers :vertical_file, class: 'mb-3' do |b|
b.use :html5
b.use :placeholder
b.optional :maxlength
b.optional :minlength
b.optional :readonly
- b.use :label
- b.use :input, class: 'form-control-file', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' }
- b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
+ b.use :label, class: 'form-label'
+ b.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid'
+ b.use :full_error, wrap_with: { class: 'invalid-feedback' }
+ b.use :hint, wrap_with: { class: 'form-text' }
end
- config.wrappers(
- :vertical_multi_select,
- tag: 'div',
- class: 'form-group',
- error_class: 'form-group-invalid',
- valid_class: 'form-group-valid'
- ) do |b|
+ # vertical select input
+ config.wrappers :vertical_select, class: 'mb-3' do |b|
b.use :html5
b.optional :readonly
- b.use :label, class: 'form-control-label'
- b.wrapper(
- tag: 'div',
- class: 'd-flex flex-row justify-content-between align-items-center'
- ) do |ba|
- ba.use :input, class: 'form-control mx-1', error_class: 'is-invalid', valid_class: 'is-valid'
+ b.use :label, class: 'form-label'
+ b.use :input, class: 'form-select', error_class: 'is-invalid', valid_class: 'is-valid'
+ b.use :full_error, wrap_with: { class: 'invalid-feedback' }
+ b.use :hint, wrap_with: { class: 'form-text' }
+ end
+
+ # vertical multi select
+ config.wrappers :vertical_multi_select, class: 'mb-3' do |b|
+ b.use :html5
+ b.optional :readonly
+ b.use :label, class: 'form-label'
+ b.wrapper class: 'd-flex flex-row justify-content-between align-items-center' do |ba|
+ ba.use :input, class: 'form-select mx-1', error_class: 'is-invalid', valid_class: 'is-valid'
end
- b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' }
- b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
+ b.use :full_error, wrap_with: { class: 'invalid-feedback d-block' }
+ b.use :hint, wrap_with: { class: 'form-text' }
end
- config.wrappers(
- :vertical_range,
- tag: 'div',
- class: 'form-group',
- error_class: 'form-group-invalid',
- valid_class: 'form-group-valid'
- ) do |b|
+ # vertical range input
+ config.wrappers :vertical_range, class: 'mb-3' do |b|
b.use :html5
b.use :placeholder
b.optional :readonly
b.optional :step
- b.use :label
- b.use :input, class: 'form-control-range', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' }
- b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
+ b.use :label, class: 'form-label'
+ b.use :input, class: 'form-range', error_class: 'is-invalid', valid_class: 'is-valid'
+ b.use :full_error, wrap_with: { class: 'invalid-feedback' }
+ b.use :hint, wrap_with: { class: 'form-text' }
end
- config.wrappers(
- :horizontal_form,
- tag: 'div',
- class: 'form-group row',
- error_class: 'form-group-invalid',
- valid_class: 'form-group-valid'
- ) do |b|
+
+ # horizontal forms
+ #
+ # horizontal default_wrapper
+ config.wrappers :horizontal_form, class: 'row mb-3' do |b|
b.use :html5
b.use :placeholder
b.optional :maxlength
@@ -155,131 +157,111 @@
b.optional :min_max
b.optional :readonly
b.use :label, class: 'col-sm-3 col-form-label'
- b.wrapper :grid_wrapper, tag: 'div', class: 'col-sm-9' do |ba|
+ b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba|
ba.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid'
- ba.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback' }
- ba.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
+ ba.use :full_error, wrap_with: { class: 'invalid-feedback' }
+ ba.use :hint, wrap_with: { class: 'form-text' }
end
end
- config.wrappers(
- :horizontal_boolean,
- tag: 'div',
- class: 'form-group row',
- error_class: 'form-group-invalid',
- valid_class: 'form-group-valid'
- ) do |b|
+ # horizontal input for boolean
+ config.wrappers :horizontal_boolean, class: 'row mb-3' do |b|
b.use :html5
b.optional :readonly
- b.wrapper tag: 'label', class: 'col-sm-3' do |ba|
- ba.use :label_text
- end
- b.wrapper :grid_wrapper, tag: 'div', class: 'col-sm-9' do |wr|
- wr.wrapper :form_check_wrapper, tag: 'div', class: 'form-check' do |bb|
+ b.wrapper :grid_wrapper, class: 'col-sm-9 offset-sm-3' do |wr|
+ wr.wrapper :form_check_wrapper, class: 'form-check' do |bb|
bb.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid'
bb.use :label, class: 'form-check-label'
- bb.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' }
- bb.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
+ bb.use :full_error, wrap_with: { class: 'invalid-feedback' }
+ bb.use :hint, wrap_with: { class: 'form-text' }
end
end
end
- config.wrappers(
- :horizontal_collection,
- item_wrapper_class: 'form-check',
- tag: 'div',
- class: 'form-group row',
- error_class: 'form-group-invalid',
- valid_class: 'form-group-valid'
- ) do |b|
+ # horizontal input for radio buttons and check boxes
+ config.wrappers :horizontal_collection, item_wrapper_class: 'form-check', item_label_class: 'form-check-label', class: 'row mb-3' do |b|
b.use :html5
b.optional :readonly
- b.use :label, class: 'col-sm-3 form-control-label'
- b.wrapper :grid_wrapper, tag: 'div', class: 'col-sm-9' do |ba|
+ b.use :label, class: 'col-sm-3 col-form-label pt-0'
+ b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba|
ba.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid'
- ba.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' }
- ba.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
+ ba.use :full_error, wrap_with: { class: 'invalid-feedback d-block' }
+ ba.use :hint, wrap_with: { class: 'form-text' }
end
end
- config.wrappers(
- :horizontal_collection_inline,
- item_wrapper_class: 'form-check form-check-inline',
- tag: 'div',
- class: 'form-group row',
- error_class: 'form-group-invalid',
- valid_class: 'form-group-valid'
- ) do |b|
+ # horizontal input for inline radio buttons and check boxes
+ config.wrappers :horizontal_collection_inline, item_wrapper_class: 'form-check form-check-inline', item_label_class: 'form-check-label', class: 'row mb-3' do |b|
b.use :html5
b.optional :readonly
- b.use :label, class: 'col-sm-3 form-control-label'
- b.wrapper :grid_wrapper, tag: 'div', class: 'col-sm-9' do |ba|
+ b.use :label, class: 'col-sm-3 col-form-label pt-0'
+ b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba|
ba.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid'
- ba.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' }
- ba.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
+ ba.use :full_error, wrap_with: { class: 'invalid-feedback d-block' }
+ ba.use :hint, wrap_with: { class: 'form-text' }
end
end
- config.wrappers(
- :horizontal_file,
- tag: 'div',
- class: 'form-group row',
- error_class: 'form-group-invalid',
- valid_class: 'form-group-valid'
- ) do |b|
+ # horizontal file input
+ config.wrappers :horizontal_file, class: 'row mb-3' do |b|
b.use :html5
b.use :placeholder
b.optional :maxlength
b.optional :minlength
b.optional :readonly
- b.use :label, class: 'col-sm-3 form-control-label'
- b.wrapper :grid_wrapper, tag: 'div', class: 'col-sm-9' do |ba|
- ba.use :input, error_class: 'is-invalid', valid_class: 'is-valid'
- ba.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' }
- ba.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
+ b.use :label, class: 'col-sm-3 col-form-label'
+ b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba|
+ ba.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid'
+ ba.use :full_error, wrap_with: { class: 'invalid-feedback' }
+ ba.use :hint, wrap_with: { class: 'form-text' }
+ end
+ end
+
+ # horizontal select input
+ config.wrappers :horizontal_select, class: 'row mb-3' do |b|
+ b.use :html5
+ b.optional :readonly
+ b.use :label, class: 'col-sm-3 col-form-label'
+ b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba|
+ ba.use :input, class: 'form-select', error_class: 'is-invalid', valid_class: 'is-valid'
+ ba.use :full_error, wrap_with: { class: 'invalid-feedback' }
+ ba.use :hint, wrap_with: { class: 'form-text' }
end
end
# horizontal multi select
- config.wrappers(
- :horizontal_multi_select,
- tag: 'div',
- class: 'form-group row',
- error_class: 'form-group-invalid',
- valid_class: 'form-group-valid'
- ) do |b|
+ config.wrappers :horizontal_multi_select, class: 'row mb-3' do |b|
b.use :html5
b.optional :readonly
- b.use :label, class: 'col-sm-3 control-label'
- b.wrapper :grid_wrapper, tag: 'div', class: 'col-sm-9' do |ba|
- ba.wrapper tag: 'div', class: 'd-flex flex-row justify-content-between align-items-center' do |bb|
- bb.use :input, class: 'form-control mx-1', error_class: 'is-invalid', valid_class: 'is-valid'
+ b.use :label, class: 'col-sm-3 col-form-label'
+ b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba|
+ ba.wrapper class: 'd-flex flex-row justify-content-between align-items-center' do |bb|
+ bb.use :input, class: 'form-select mx-1', error_class: 'is-invalid', valid_class: 'is-valid'
end
- ba.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' }
- ba.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
+ ba.use :full_error, wrap_with: { class: 'invalid-feedback d-block' }
+ ba.use :hint, wrap_with: { class: 'form-text' }
end
end
- config.wrappers(
- :horizontal_range,
- tag: 'div',
- class: 'form-group row',
- error_class: 'form-group-invalid',
- valid_class: 'form-group-valid'
- ) do |b|
+ # horizontal range input
+ config.wrappers :horizontal_range, class: 'row mb-3' do |b|
b.use :html5
b.use :placeholder
b.optional :readonly
b.optional :step
- b.use :label, class: 'col-sm-3 form-control-label'
- b.wrapper :grid_wrapper, tag: 'div', class: 'col-sm-9' do |ba|
- ba.use :input, class: 'form-control-range', error_class: 'is-invalid', valid_class: 'is-valid'
- ba.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' }
- ba.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
+ b.use :label, class: 'col-sm-3 col-form-label pt-0'
+ b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba|
+ ba.use :input, class: 'form-range', error_class: 'is-invalid', valid_class: 'is-valid'
+ ba.use :full_error, wrap_with: { class: 'invalid-feedback' }
+ ba.use :hint, wrap_with: { class: 'form-text' }
end
end
- config.wrappers :inline_form, tag: 'span', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b|
+
+ # inline forms
+ #
+ # inline default_wrapper
+ config.wrappers :inline_form, class: 'col-12' do |b|
b.use :html5
b.use :placeholder
b.optional :maxlength
@@ -287,116 +269,66 @@
b.optional :pattern
b.optional :min_max
b.optional :readonly
- b.use :label, class: 'sr-only'
+ b.use :label, class: 'visually-hidden'
b.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :error, wrap_with: { tag: 'div', class: 'invalid-feedback' }
- b.optional :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
+ b.use :error, wrap_with: { class: 'invalid-feedback' }
+ b.optional :hint, wrap_with: { class: 'form-text' }
end
# inline input for boolean
- config.wrappers :inline_boolean, tag: 'span', class: 'form-check flex-wrap justify-content-start mr-sm-2', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b|
+ config.wrappers :inline_boolean, class: 'col-12' do |b|
b.use :html5
b.optional :readonly
- b.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :label, class: 'form-check-label'
- b.use :error, wrap_with: { tag: 'div', class: 'invalid-feedback' }
- b.optional :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
+ b.wrapper :form_check_wrapper, class: 'form-check' do |bb|
+ bb.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid'
+ bb.use :label, class: 'form-check-label'
+ bb.use :error, wrap_with: { class: 'invalid-feedback' }
+ bb.optional :hint, wrap_with: { class: 'form-text' }
+ end
end
+
# bootstrap custom forms
#
- # custom input for boolean
- config.wrappers :custom_boolean, tag: 'fieldset', class: 'form-group', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b|
- b.use :html5
- b.optional :readonly
- b.wrapper :form_check_wrapper, tag: 'div', class: 'custom-control custom-checkbox' do |bb|
- bb.use :input, class: 'custom-control-input', error_class: 'is-invalid', valid_class: 'is-valid'
- bb.use :label, class: 'custom-control-label'
- bb.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback' }
- bb.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
- end
- end
-
- config.wrappers :custom_boolean_switch, tag: 'fieldset', class: 'form-group', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b|
+ # custom input switch for boolean
+ config.wrappers :custom_boolean_switch, class: 'mb-3' do |b|
b.use :html5
b.optional :readonly
- b.wrapper :form_check_wrapper, tag: 'div', class: 'custom-control custom-checkbox-switch' do |bb|
- bb.use :input, class: 'custom-control-input', error_class: 'is-invalid', valid_class: 'is-valid'
- bb.use :label, class: 'custom-control-label'
+ b.wrapper :form_check_wrapper, tag: 'div', class: 'form-check form-switch' do |bb|
+ bb.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid'
+ bb.use :label, class: 'form-check-label'
bb.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback' }
- bb.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
+ bb.use :hint, wrap_with: { class: 'form-text' }
end
end
- # custom input for radio buttons and check boxes
- config.wrappers :custom_collection, item_wrapper_class: 'custom-control', tag: 'fieldset', class: 'form-group', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b|
- b.use :html5
- b.optional :readonly
- b.wrapper :legend_tag, tag: 'legend', class: 'col-form-label pt-0' do |ba|
- ba.use :label_text
- end
- b.use :input, class: 'custom-control-input', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' }
- b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
- end
- # custom input for inline radio buttons and check boxes
- config.wrappers :custom_collection_inline, item_wrapper_class: 'custom-control custom-control-inline', tag: 'fieldset', class: 'form-group', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b|
- b.use :html5
- b.optional :readonly
- b.wrapper :legend_tag, tag: 'legend', class: 'col-form-label pt-0' do |ba|
- ba.use :label_text
- end
- b.use :input, class: 'custom-control-input', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' }
- b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
- end
-
- # custom file input
- config.wrappers :custom_file, tag: 'div', class: 'form-group', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b|
+ # Input Group - custom component
+ # see example app and config at https://github.com/heartcombo/simple_form-bootstrap
+ config.wrappers :input_group, class: 'mb-3' do |b|
b.use :html5
b.use :placeholder
b.optional :maxlength
b.optional :minlength
+ b.optional :pattern
+ b.optional :min_max
b.optional :readonly
- b.use :label, class: 'form-control-label'
- b.wrapper :custom_file_wrapper, tag: 'div', class: 'custom-file' do |ba|
- ba.use :input, class: 'custom-file-input', error_class: 'is-invalid', valid_class: 'is-valid'
- ba.use :label, class: 'custom-file-label'
- ba.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback' }
- end
- b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
- end
-
- # custom multi select
- config.wrappers :custom_multi_select, tag: 'div', class: 'form-group', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b|
- b.use :html5
- b.optional :readonly
- b.use :label, class: 'form-control-label'
- b.wrapper tag: 'div', class: 'd-flex flex-row justify-content-between align-items-center' do |ba|
- ba.use :input, class: 'custom-select mx-1', error_class: 'is-invalid', valid_class: 'is-valid'
+ b.use :label, class: 'form-label'
+ b.wrapper :input_group_tag, class: 'input-group' do |ba|
+ ba.optional :prepend
+ ba.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid'
+ ba.optional :append
+ ba.use :full_error, wrap_with: { class: 'invalid-feedback' }
end
- b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' }
- b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
+ b.use :hint, wrap_with: { class: 'form-text' }
end
- # custom range input
- config.wrappers :custom_range, tag: 'div', class: 'form-group', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b|
- b.use :html5
- b.use :placeholder
- b.optional :readonly
- b.optional :step
- b.use :label, class: 'form-control-label'
- b.use :input, class: 'custom-range', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback d-block' }
- b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
- end
# Floating Labels form
#
# floating labels default_wrapper
- config.wrappers :floating_labels_form, tag: 'div', class: 'form-label-group', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b|
+ config.wrappers :floating_labels_form, class: 'form-floating mb-3' do |b|
b.use :html5
b.use :placeholder
b.optional :maxlength
@@ -405,21 +337,22 @@
b.optional :min_max
b.optional :readonly
b.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :label, class: 'form-control-label'
- b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback' }
- b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
+ b.use :label
+ b.use :full_error, wrap_with: { class: 'invalid-feedback' }
+ b.use :hint, wrap_with: { class: 'form-text' }
end
# custom multi select
- config.wrappers :floating_labels_select, tag: 'div', class: 'form-label-group', error_class: 'form-group-invalid', valid_class: 'form-group-valid' do |b|
+ config.wrappers :floating_labels_select, class: 'form-floating mb-3' do |b|
b.use :html5
b.optional :readonly
- b.use :input, class: 'custom-select custom-select-lg', error_class: 'is-invalid', valid_class: 'is-valid'
- b.use :label, class: 'form-control-label'
- b.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback' }
- b.use :hint, wrap_with: { tag: 'small', class: 'form-text text-muted' }
+ b.use :input, class: 'form-select', error_class: 'is-invalid', valid_class: 'is-valid'
+ b.use :label
+ b.use :full_error, wrap_with: { class: 'invalid-feedback' }
+ b.use :hint, wrap_with: { class: 'form-text' }
end
+
# The default wrapper to be used by the FormBuilder.
config.default_wrapper = :vertical_form
@@ -433,6 +366,7 @@
file: :vertical_file,
radio_buttons: :vertical_collection,
range: :vertical_range,
- time: :vertical_multi_select
+ time: :vertical_multi_select,
+ select: :vertical_select
}
end
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
new file mode 100644
index 0000000..2374383
--- /dev/null
+++ b/config/locales/simple_form.en.yml
@@ -0,0 +1,31 @@
+en:
+ simple_form:
+ "yes": 'Yes'
+ "no": 'No'
+ required:
+ text: 'required'
+ mark: '*'
+ # You can uncomment the line below if you need to overwrite the whole required html.
+ # When using html, text and mark won't be used.
+ # html: '*'
+ error_notification:
+ default_message: "Please review the problems below:"
+ # Examples
+ # labels:
+ # defaults:
+ # password: 'Password'
+ # user:
+ # new:
+ # email: 'E-mail to sign in.'
+ # edit:
+ # email: 'E-mail.'
+ # hints:
+ # defaults:
+ # username: 'User name to sign in.'
+ # password: 'No special characters, please.'
+ # include_blanks:
+ # defaults:
+ # age: 'Rather not say'
+ # prompts:
+ # defaults:
+ # age: 'Select your age'
diff --git a/config/secrets.yml.example b/config/secrets.yml.example
deleted file mode 100644
index 79c31cd..0000000
--- a/config/secrets.yml.example
+++ /dev/null
@@ -1,22 +0,0 @@
-development:
- domain_name: example.com
- secret_key_base: fef239dbdf8f5603739f7940fa0420c8021415f9e8649eee3d6010c5e14b9d2d136e9416493691f347ffe07548661c215e1abfd541493511ddedc8297e3972a9
- twitter_app_id: ''
- twitter_secret: ''
- castle_app_id: ''
- castle_secret: ''
-
-test:
- secret_key_base: 1aeecc02bb42f45d82d192643a96e437c6a982a1f60c015c520ea2ca48fbbaca8ccaae9f63586402364159af81b59e5a30b6dba7a18ded91bb8473ef78d07eec
- twitter_app_id: ''
- twitter_secret: ''
- castle_app_id: ''
- castle_secret: ''
-
-production:
- domain_name: <%= ENV['DOMAIN_NAME'] %>
- secret_key_base: <%= ENV['SECRET_KEY_BASE'] %>
- twitter_app_id: <%= ENV['TWITTER_APP_ID'] %>
- twitter_secret: <%= ENV['TWITTER_SECRET'] %>
- castle_app_id: <%= ENV['CASTLE_APP_ID'] %>
- castle_secret: <%= ENV['CASTLE_SECRET'] %>
diff --git a/config/storage.yml b/config/storage.yml
new file mode 100644
index 0000000..695f17b
--- /dev/null
+++ b/config/storage.yml
@@ -0,0 +1,7 @@
+test:
+ service: Disk
+ root: <%= Rails.root.join("tmp/storage") %>
+
+local:
+ service: Disk
+ root: <%= Rails.root.join("storage") %>
diff --git a/lib/integrations/castle_webhook_verifier.rb b/lib/integrations/castle_webhook_verifier.rb
deleted file mode 100644
index 8dc15cf..0000000
--- a/lib/integrations/castle_webhook_verifier.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-# frozen_string_literal: true
-
-%w[
- base64
- openssl
-].each(&method(:require))
-
-module Integrations
- # Module used to verify that incoming webhook was sent by Castle itself
- module CastleWebhookVerifier
- class << self
- # @param incoming_data [String] incoming raw JSON data string
- # @param incoming_signature [String] X-Castle-Signature value
- # @return [Boolean] true if the incoming webhook request is valid and sent by Castle
- def valid?(incoming_data, incoming_signature)
- expected_signature = Base64.encode64(
- OpenSSL::HMAC.digest(
- OpenSSL::Digest::Digest.new('sha256'),
- Rails.application.secrets.castle_secret,
- incoming_data
- )
- ).strip
-
- incoming_signature == expected_signature
- end
- end
- end
-end
diff --git a/package.json b/package.json
deleted file mode 100644
index 5cde0a4..0000000
--- a/package.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "name": "rails-bootstrap",
- "private": true,
- "dependencies": {}
-}
diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb
index 0665bb3..1884d88 100644
--- a/spec/controllers/application_controller_spec.rb
+++ b/spec/controllers/application_controller_spec.rb
@@ -1,5 +1,36 @@
# frozen_string_literal: true
RSpec.describe ApplicationController do
- pending
+ controller do
+ def index
+ render plain: 'authenticated'
+ end
+ end
+
+ before { request.env['devise.mapping'] = Devise.mappings[:user] }
+
+ describe 'authentication' do
+ context 'when not signed in' do
+ before { get :index }
+
+ it { expect(response).to redirect_to new_user_session_path }
+ end
+
+ context 'when signed in' do
+ with_user
+
+ before { get :index }
+
+ it { expect(response).to have_http_status(:ok) }
+ it { expect(response.body).to eq 'authenticated' }
+ end
+ end
+
+ describe '#castle_request_token' do
+ it 'returns the submitted request token param' do
+ controller.params[:castle_request_token] = 'tok_123'
+
+ expect(controller.send(:castle_request_token)).to eq 'tok_123'
+ end
+ end
end
diff --git a/spec/controllers/integrations/castle_webhooks_controller_spec.rb b/spec/controllers/integrations/castle_webhooks_controller_spec.rb
index c71d360..60fe47d 100644
--- a/spec/controllers/integrations/castle_webhooks_controller_spec.rb
+++ b/spec/controllers/integrations/castle_webhooks_controller_spec.rb
@@ -20,6 +20,16 @@
it { expect { create_request }.to raise_error(ActionController::RoutingError) }
end
+ context 'when the signature is present but does not match' do
+ let(:headers) do
+ { 'X-Castle-Signature' => 'definitely-not-a-valid-signature', 'Content-Type' => 'application/json' }
+ end
+
+ before { allow(Castle.config).to receive(:api_secret).and_return('some-secret') }
+
+ it { expect { create_request }.to raise_error(ActionController::RoutingError) }
+ end
+
context 'when request was send by the Castle backend' do
let(:castle_secret) { 'V9Q86iQBWi4xAbleSMrk4+cYhoUMIiiHvIwMl9jh9uo=' }
let(:headers) do
@@ -37,9 +47,7 @@
end
before do
- allow(Rails.application.secrets)
- .to receive(:castle_secret)
- .and_return(castle_secret)
+ allow(Castle.config).to receive(:api_secret).and_return(castle_secret)
end
it 'expect to create a webhook in the local db' do
diff --git a/spec/controllers/users/omniauth_callbacks_controller_spec.rb b/spec/controllers/users/omniauth_callbacks_controller_spec.rb
index 8cd6934..982ff2c 100644
--- a/spec/controllers/users/omniauth_callbacks_controller_spec.rb
+++ b/spec/controllers/users/omniauth_callbacks_controller_spec.rb
@@ -7,62 +7,87 @@
describe '#twitter' do
let(:user) { create(:user, id: user_id) }
- let(:user_attributes) { user.attributes }
before do
- user_attributes
allow(User).to receive(:find_or_create_for_oauth).and_return(user)
end
context 'when user is valid and persisted' do
- let(:castle_auth_args) do
+ let(:risk_args) do
{
- event: '$login.succeeded',
- user_id: user.id,
- user_traits: user_attributes
+ type: '$login',
+ status: '$succeeded',
+ request_token: nil,
+ user: { id: user.id, email: user.email }
}
end
before do
- allow(controller.castle).to receive(:authenticate).and_return(verdict)
+ allow(controller.castle).to receive(:risk).and_return(verdict)
get :twitter
end
context 'when user allowed' do
- let(:verdict) { { action: 'allow' } }
+ let(:verdict) { { policy: { action: 'allow' } } }
it { expect(response).to redirect_to root_path }
- it { expect(controller.castle).to have_received(:authenticate).with(castle_auth_args) }
+ it { expect(controller.castle).to have_received(:risk).with(risk_args) }
end
context 'when user challenged' do
- let(:verdict) { { action: 'challenge' } }
+ let(:verdict) { { policy: { action: 'challenge' } } }
it { expect(response).to redirect_to root_path }
- it { expect(controller.castle).to have_received(:authenticate).with(castle_auth_args) }
+ it { expect(controller.castle).to have_received(:risk).with(risk_args) }
end
context 'when user denied' do
- let(:verdict) { { action: 'deny' } }
+ let(:verdict) { { policy: { action: 'deny' } } }
let(:error_message) { I18n.t('users.omniauth_callbacks.twitter.access_denied') }
it { expect(response).to redirect_to new_user_session_path }
it { expect(flash['error']).to eq error_message }
- it { expect(controller.castle).to have_received(:authenticate).with(castle_auth_args) }
+ it { expect(controller.castle).to have_received(:risk).with(risk_args) }
+ end
+ end
+
+ context 'when Castle raises during risk assessment' do
+ before do
+ allow(controller.castle).to receive(:risk).and_raise(Castle::Error)
+ get :twitter
+ end
+
+ it 'fails open and signs the user in' do
+ expect(response).to redirect_to root_path
end
end
context 'when user is not valid and not persisted' do
let(:user) { build(:user, id: user_id) }
- let(:castle_track_args) { { event: '$login.failed', user_id: user_id } }
+ let(:filter_args) do
+ { type: '$login', status: '$failed', request_token: nil, user: { id: user_id } }
+ end
before do
- allow(controller.castle).to receive(:track)
+ allow(controller.castle).to receive(:filter)
get :twitter
end
it { expect(response).to redirect_to new_user_registration_path }
- it { expect(controller.castle).to have_received(:track).with(castle_track_args) }
+ it { expect(controller.castle).to have_received(:filter).with(filter_args) }
+ end
+
+ context 'when user is not persisted and Castle raises' do
+ let(:user) { build(:user, id: user_id) }
+
+ before do
+ allow(controller.castle).to receive(:filter).and_raise(Castle::Error)
+ get :twitter
+ end
+
+ it 'still redirects without surfacing the error' do
+ expect(response).to redirect_to new_user_registration_path
+ end
end
end
end
diff --git a/spec/controllers/users/passwords_controller_spec.rb b/spec/controllers/users/passwords_controller_spec.rb
index d99780f..1de562e 100644
--- a/spec/controllers/users/passwords_controller_spec.rb
+++ b/spec/controllers/users/passwords_controller_spec.rb
@@ -1,5 +1,9 @@
# frozen_string_literal: true
+# NOTE: the User model does not enable Devise's :recoverable module, so no
+# password-reset routes are mounted. These specs therefore assert the
+# controller wiring rather than exercising HTTP requests.
RSpec.describe Users::PasswordsController do
- pending
+ it { expect(described_class.ancestors).to include(Devise::PasswordsController) }
+ it { expect(described_class._layout).to eq 'devise' }
end
diff --git a/spec/controllers/users/profiles_controller_spec.rb b/spec/controllers/users/profiles_controller_spec.rb
index 84a5d7a..75cf4a3 100644
--- a/spec/controllers/users/profiles_controller_spec.rb
+++ b/spec/controllers/users/profiles_controller_spec.rb
@@ -12,13 +12,13 @@
with_user
before do
- allow(controller.castle).to receive(:track)
+ allow(controller.castle).to receive(:log)
get :edit
end
it { expect(response).to render_template(:edit) }
it { expect(response).to have_http_status(:ok) }
- it { expect(controller.castle).not_to have_received(:track) }
+ it { expect(controller.castle).not_to have_received(:log) }
end
end
@@ -36,41 +36,54 @@
context 'with invalid data' do
let(:params) { { user: { email: '' } } }
- let(:track_expected_data) do
+ let(:log_expected_data) do
{
- event: '$profile_update.failed',
- user_id: controller.current_user.id,
- user_traits: controller.current_user.attributes
+ type: '$profile_update',
+ status: '$failed',
+ user: { id: controller.current_user.id, email: controller.current_user.email }
}
end
before do
- allow(controller.castle).to receive(:track)
+ allow(controller.castle).to receive(:log)
put :update, params: params
end
it { expect(response).to render_template(:edit) }
it { expect(response).to have_http_status(:ok) }
- it { expect(controller.castle).to have_received(:track).with(track_expected_data) }
+ it { expect(controller.castle).to have_received(:log).with(log_expected_data) }
end
context 'with valid data' do
let(:params) { { user: { email: Faker::Internet.email } } }
- let(:track_expected_data) do
+ let(:log_expected_data) do
{
- event: '$profile_update.succeeded',
- user_id: controller.current_user.id,
- user_traits: controller.current_user.attributes
+ type: '$profile_update',
+ status: '$succeeded',
+ user: { id: controller.current_user.id, email: controller.current_user.email }
}
end
before do
- allow(controller.castle).to receive(:track)
+ allow(controller.castle).to receive(:log)
put :update, params: params
end
it { expect(response).to redirect_to root_path }
- it { expect(controller.castle).to have_received(:track).with(track_expected_data) }
+ it { expect(controller.castle).to have_received(:log).with(log_expected_data) }
+ end
+
+ context 'when Castle raises while logging' do
+ let(:params) { { user: { email: Faker::Internet.email } } }
+
+ before do
+ allow(controller.castle).to receive(:log).and_raise(Castle::Error)
+ put :update, params: params
+ end
+
+ it 'still completes the update without surfacing the error' do
+ expect(response).to redirect_to root_path
+ end
end
end
end
diff --git a/spec/controllers/users/registrations_controller_spec.rb b/spec/controllers/users/registrations_controller_spec.rb
index 7835872..90cda33 100644
--- a/spec/controllers/users/registrations_controller_spec.rb
+++ b/spec/controllers/users/registrations_controller_spec.rb
@@ -1,5 +1,30 @@
# frozen_string_literal: true
RSpec.describe Users::RegistrationsController do
- pending
+ before { request.env['devise.mapping'] = Devise.mappings[:user] }
+
+ describe 'GET #new' do
+ before { get :new }
+
+ it { expect(response).to have_http_status(:ok) }
+ it { expect(response).to render_template(:new) }
+ end
+
+ describe 'POST #create' do
+ let(:password) { 'sup3r-s3cret' }
+ let(:params) do
+ { user: { email: Faker::Internet.email, password: password, password_confirmation: password } }
+ end
+
+ it 'creates a new user' do
+ expect { post :create, params: params }.to change(User, :count).by(1)
+ end
+
+ it 'signs the user in' do
+ post :create, params: params
+
+ expect(controller.current_user).to be_present
+ expect(response).to redirect_to root_path
+ end
+ end
end
diff --git a/spec/controllers/users/sessions_controller_spec.rb b/spec/controllers/users/sessions_controller_spec.rb
index 9ecea5c..4f8d5a9 100644
--- a/spec/controllers/users/sessions_controller_spec.rb
+++ b/spec/controllers/users/sessions_controller_spec.rb
@@ -5,67 +5,90 @@
describe 'GET new' do
before do
- allow(controller.castle).to receive(:authenticate)
+ allow(controller.castle).to receive(:risk)
get :new
end
it { expect(response.status).to eq 200 }
it { expect(response).to render_template 'new' }
- it { expect(controller.castle).not_to have_received(:authenticate) }
+ it { expect(controller.castle).not_to have_received(:risk) }
end
describe 'POST create' do
let(:password) { rand.to_s }
let(:user) { create(:user, password: password, password_confirmation: password) }
- # @note We cannot directly check the castle invocation because of the way warde works
+ # @note We cannot directly check the castle invocation because of the way warden works
# and how it redirects
context 'when login failed' do
before do
# Since the expectations are handled after the redirect for invalid, we don't have a way
# to reference the "future" castle object, so we have to stub all the instances
- allow_any_instance_of(controller.castle.class).to receive(:track)
+ allow_any_instance_of(controller.castle.class).to receive(:filter)
post :create, params: { user: { email: user.email, password: rand.to_s } }
end
it { expect(response).to redirect_to new_user_session_path }
end
+ context 'when login failed and Castle raises an error' do
+ before do
+ allow_any_instance_of(controller.castle.class).to receive(:filter).and_raise(Castle::Error)
+ post :create, params: { user: { email: user.email, password: rand.to_s } }
+ end
+
+ it 'still redirects without surfacing the error' do
+ expect(response).to redirect_to new_user_session_path
+ end
+ end
+
context 'when login succeeded' do
- let(:castle_auth_args) do
+ let(:risk_args) do
{
- event: '$login.succeeded',
- user_id: user.id,
- user_traits: controller.current_user.attributes
+ type: '$login',
+ status: '$succeeded',
+ request_token: nil,
+ user: { id: user.id, email: user.email }
}
end
before do
- allow(controller.castle).to receive(:authenticate).and_return(verdict)
+ allow(controller.castle).to receive(:risk).and_return(verdict)
post :create, params: { user: { email: user.email, password: password } }
end
context 'when user allowed' do
- let(:verdict) { { action: 'allow' } }
+ let(:verdict) { { policy: { action: 'allow' } } }
it { expect(response).to redirect_to root_path }
- it { expect(controller.castle).to have_received(:authenticate).with(castle_auth_args) }
+ it { expect(controller.castle).to have_received(:risk).with(risk_args) }
end
context 'when user challenged' do
- let(:verdict) { { action: 'challenge' } }
+ let(:verdict) { { policy: { action: 'challenge' } } }
it { expect(response).to redirect_to root_path }
- it { expect(controller.castle).to have_received(:authenticate).with(castle_auth_args) }
+ it { expect(controller.castle).to have_received(:risk).with(risk_args) }
end
context 'when user denied' do
- let(:verdict) { { action: 'deny' } }
- let(:error_message) { I18n.t('users.omniauth_callbacks.twitter.access_denied') }
+ let(:verdict) { { policy: { action: 'deny' } } }
+ let(:error_message) { I18n.t('users.sessions.create.access_denied') }
it { expect(response).to redirect_to new_user_session_path }
it { expect(flash['error']).to eq error_message }
- it { expect(controller.castle).to have_received(:authenticate).with(castle_auth_args) }
+ it { expect(controller.castle).to have_received(:risk).with(risk_args) }
+ end
+ end
+
+ context 'when Castle raises during risk assessment' do
+ before do
+ allow(controller.castle).to receive(:risk).and_raise(Castle::Error)
+ post :create, params: { user: { email: user.email, password: password } }
+ end
+
+ it 'fails open and allows the login' do
+ expect(response).to redirect_to root_path
end
end
end
@@ -73,15 +96,15 @@
describe 'DELETE destroy' do
with_user
- let(:castle_track_args) { { event: '$logout.succeeded', user_id: user.id } }
+ let(:log_args) { { type: '$logout', status: '$succeeded', user: { id: user.id } } }
before do
- allow(controller.castle).to receive(:track)
+ allow(controller.castle).to receive(:log)
delete :destroy
end
it { expect(flash[:notice]).to eq I18n.t('devise.sessions.signed_out') }
it { expect(response).to redirect_to root_path }
- it { expect(controller.castle).to have_received(:track).with(castle_track_args) }
+ it { expect(controller.castle).to have_received(:log).with(log_args) }
end
end
diff --git a/spec/lib/application_responder_spec.rb b/spec/lib/application_responder_spec.rb
index 5d7cf45..5cc6077 100644
--- a/spec/lib/application_responder_spec.rb
+++ b/spec/lib/application_responder_spec.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
RSpec.describe ApplicationResponder do
- pending
+ it { expect(described_class.superclass).to eq ActionController::Responder }
+ it { expect(described_class.ancestors).to include(Responders::FlashResponder) }
+ it { expect(described_class.ancestors).to include(Responders::HttpCacheResponder) }
end
diff --git a/spec/lib/integrations/castle_webhook_verifier_spec.rb b/spec/lib/integrations/castle_webhook_verifier_spec.rb
deleted file mode 100644
index 06fee82..0000000
--- a/spec/lib/integrations/castle_webhook_verifier_spec.rb
+++ /dev/null
@@ -1,41 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.describe Integrations::CastleWebhookVerifier do
- subject(:result) { described_class.valid?(incoming_data, incoming_signature) }
-
- let(:incoming_data) { rand.to_s }
-
- context 'when incoming_signature is empty' do
- let(:incoming_signature) { '' }
-
- it { is_expected.to be false }
- end
-
- context 'when incoming signature does not match data' do
- let(:incoming_signature) { 'thNnmggU2ex3L5XXeMNfxf8Wl8STcVZTxscSFEKSxa0=' }
-
- it { is_expected.to be false }
- end
-
- context 'when incoming_signature matches the data' do
- let(:castle_secret) { 'V9Q86iQBWi4xAbleSMrk4+cYhoUMIiiHvIwMl9jh9uo=' }
- let(:incoming_signature) { 'c5rwq+SNCY8oyeOVKdvscmNIzMOmTb9U6VB/Iv6A+ys=' }
- let(:incoming_data) do
- {
- 'api_version': 'v1',
- 'app_id': '12345678901234',
- 'type': '$review.opened'
- }.to_json
- end
-
- # We need to stub the castle secret for this particular case, because we cannot rely on
- # a user one for hard-coded signature example as the valid signature was pre-calculated
- before do
- allow(Rails.application.secrets)
- .to receive(:castle_secret)
- .and_return(castle_secret)
- end
-
- it { is_expected.to be true }
- end
-end
diff --git a/spec/models/application_record_spec.rb b/spec/models/application_record_spec.rb
index ab7a69d..f840bdc 100644
--- a/spec/models/application_record_spec.rb
+++ b/spec/models/application_record_spec.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
RSpec.describe ApplicationRecord do
- pending
+ it { expect(described_class.abstract_class).to be true }
+ it { expect(described_class.superclass).to eq ActiveRecord::Base }
end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index f07fd66..f5893fd 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -1,5 +1,43 @@
# frozen_string_literal: true
RSpec.describe User do
- pending
+ describe '.find_or_create_for_oauth' do
+ let(:email) { Faker::Internet.email }
+ let(:auth) do
+ OmniAuth::AuthHash.new(provider: 'twitter', uid: '12345', info: { email: email })
+ end
+
+ context 'when no matching user exists' do
+ it 'creates a new user' do
+ expect { described_class.find_or_create_for_oauth(auth) }
+ .to change(described_class, :count).by(1)
+ end
+
+ it 'persists the provider, uid and email' do
+ user = described_class.find_or_create_for_oauth(auth)
+
+ expect(user).to have_attributes(
+ provider: 'twitter',
+ uid: '12345',
+ email: email,
+ persisted?: true
+ )
+ end
+ end
+
+ context 'when a user with the same provider and uid exists' do
+ let!(:existing) do
+ create(:user, provider: 'twitter', uid: '12345', email: Faker::Internet.email)
+ end
+
+ it 'does not create another user' do
+ expect { described_class.find_or_create_for_oauth(auth) }
+ .not_to change(described_class, :count)
+ end
+
+ it 'returns the existing record' do
+ expect(described_class.find_or_create_for_oauth(auth).id).to eq existing.id
+ end
+ end
+ end
end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 5834999..3153133 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -2,7 +2,7 @@
require 'simplecov'
-SimpleCov.minimum_coverage 50
+SimpleCov.minimum_coverage 95
# Don't include unnecessary stuff into rcov
SimpleCov.start do