Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ source 'https://rubygems.org'
ruby file: '.ruby-version'

gem 'bootsnap', require: false
gem 'castle-rb', '~> 8.1'
gem 'castle-rb', '~> 9.1'
gem 'devise', '~> 5.0'
gem 'dotenv-rails'
gem 'hamlit-rails'
Expand Down
5 changes: 3 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ GEM
builder (3.3.0)
byebug (13.0.0)
reline (>= 0.6.0)
castle-rb (8.1.0)
castle-rb (9.1.0)
base64 (~> 0.2)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.6)
Expand Down Expand Up @@ -309,7 +310,7 @@ PLATFORMS
DEPENDENCIES
bootsnap
byebug
castle-rb (~> 8.1)
castle-rb (~> 9.1)
devise (~> 5.0)
dotenv-rails
factory_bot_rails
Expand Down
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
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).
SDK (9.x).

## What's demonstrated

Expand All @@ -12,9 +12,14 @@ SDK (8.x).
- **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 & custom events** – recorded with the non-blocking
`log` endpoint. The custom event is available from the profile page, once
signed in.
- **logout, profile updates, custom events & password reset** – recorded with
the non-blocking `log` endpoint. The custom event is available from the
profile page, and Lists / Privacy / Password reset from the nav, once signed
in.
- **Lists API** – create a list and fetch all lists with `create_list` /
`get_all_lists`.
- **Privacy API** – honor GDPR/CCPA access and erasure requests with
`request_user_data` / `delete_user_data`.
- **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
Expand Down
2 changes: 1 addition & 1 deletion app/assets/builds/tailwind.css

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions app/controllers/users/lists_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

module Users
# Demonstrates the Lists API: create a list and fetch all lists. These are
# account-level API-secret operations, so they live behind the default
# `authenticate_user!` like the rest of the demo.
class ListsController < ApplicationController
# Renders the form (and any result from a previous POST).
def show; end

# Creates a list and then fetches every list, echoing the Castle responses.
def create
@payload = {
name: params[:name].presence || 'demo-blocklist',
color: params[:color].presence || '$red',
primary_field: params[:primary_field].presence || 'user.email'
}

created = castle.create_list(@payload)
all_lists = castle.get_all_lists
@result = { created: created, all_lists: all_lists }
rescue Castle::Error => e
@error = e.message
ensure
render :show
end
end
end
30 changes: 30 additions & 0 deletions app/controllers/users/password_resets_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# frozen_string_literal: true

module Users
# Demonstrates recording a password reset. We assume the user already passed
# the reset challenge (e.g. an emailed OTP) and record the outcome with the
# non-blocking `log` endpoint. The password is not actually changed.
class PasswordResetsController < ApplicationController
# Renders the form (and any result from a previous POST).
def show; end

# Reusing the current password counts as a failed reset; any other value is
# a successful one. Either way we only log the event to Castle.
def create
status = current_user.valid_password?(params[:password].to_s) ? '$failed' : '$succeeded'
@status = status

castle.log(
type: '$password_reset',
status: status,
request_token: castle_request_token,
user: { id: current_user.id, email: current_user.email }
)
@logged = true
rescue Castle::Error => e
@error = e.message
ensure
render :show
end
end
end
31 changes: 31 additions & 0 deletions app/controllers/users/privacy_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

module Users
# Demonstrates the Privacy API (GDPR/CCPA): request or delete user data for a
# given identifier.
class PrivacyController < ApplicationController
# Renders the form (and any result from a previous POST).
def show; end

# Calls the request- or delete-user-data endpoint depending on which button
# was used, echoing the Castle response.
def create
@payload = {
identifier: params[:identifier].presence || current_user.email,
identifier_type: params[:identifier_type].presence || '$email'
}
@action = params[:commit_action] == 'delete' ? 'delete' : 'request'

@result =
if @action == 'delete'
castle.delete_user_data(@payload)
else
castle.request_user_data(@payload)
end
rescue Castle::Error => e
@error = e.message
ensure
render :show
end
end
end
3 changes: 3 additions & 0 deletions app/views/layouts/application.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
= link_to t('.nav.webhooks'), integrations_castle_webhooks_path

- if user_signed_in?
= link_to t('.nav.lists'), users_lists_path
= link_to t('.nav.privacy'), users_privacy_path
= link_to t('.nav.password_reset'), users_password_reset_path
= link_to t('.nav.edit_password'), edit_user_registration_path
= link_to t('.nav.edit_profile'), edit_users_profile_path
= button_to t('.nav.sign_out'), destroy_user_session_path, method: :delete, form: { data: { castle: true } }
Expand Down
8 changes: 6 additions & 2 deletions app/views/main/index.html.haml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.hero
%span.tag castle-rb 8.1 · rails
%span.tag castle-rb 9.1 · rails
%h1{ class: 'text-[2rem] mt-3' } Castle Rails example app
%p.lead.mt-2
A minimal Ruby on Rails + Devise application showing how to wire the
Expand All @@ -24,10 +24,14 @@
%code filter
and the verdict can allow, challenge or deny the user.
%li
%strong Logout, profile updates & custom events
%strong Logout, profile updates, custom events & password reset
&mdash; recorded with the non-blocking
%code log
endpoint.
%li
%strong Lists & Privacy APIs
&mdash; manage allow/block lists and honor GDPR/CCPA data requests
(available from the nav once signed in).
%li
%strong Webhooks
&mdash; incoming Castle webhooks are signature-verified and listed under
Expand Down
25 changes: 25 additions & 0 deletions app/views/users/lists/show.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.section-head
%h2{ class: 'text-[1.4rem]' } Lists API

%p.lead
Manage allow/block lists programmatically. This demo calls
%code create_list
and then
%code get_all_lists
\. A valid Castle API secret is required.

.card.mt-4{ class: 'max-w-[640px]' }
= form_with url: users_lists_path, method: :post do
.field
%label{ for: 'name' } name
= text_field_tag :name, params[:name].presence || 'demo-blocklist', class: 'input'
.field
%label{ for: 'color' } color
= text_field_tag :color, params[:color].presence || '$red', class: 'input'
.field
%label{ for: 'primary_field' } primary field
= text_field_tag :primary_field, params[:primary_field].presence || 'user.email', class: 'input'
.btn-row
= submit_tag 'Create list', class: 'btn btn-primary'

= render 'users/shared/api_result', error: @error, result: @result
36 changes: 36 additions & 0 deletions app/views/users/password_resets/show.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
.section-head
%h2{ class: 'text-[1.4rem]' } Password reset

%p.lead
Records the password-reset event with the non-blocking
%code log
endpoint, which stores the event without returning a verdict.

%p.text-muted{ class: 'text-[0.9rem]' }
Assume the user already passed your reset challenge (e.g. an emailed OTP).
Enter a value different from your current password to send
%code $password_reset / $succeeded
\, or your current password to send
%code $password_reset / $failed
\. (The password is not actually changed.)

.card.mt-4{ class: 'max-w-[640px]' }
= form_with url: users_password_reset_path, method: :post, data: { castle: true } do
.field
%label email
= email_field_tag :email, current_user.email, class: 'input', disabled: true
.field
%label{ for: 'password' } new password
= password_field_tag :password, nil, class: 'input'
.btn-row
= submit_tag 'Submit', class: 'btn btn-primary'

- if @error
.alert.alert-danger.mt-4= @error
- elsif @logged
.alert.alert-success.mt-4
Logged
%code= "$password_reset / #{@status}"
via the
%code log
endpoint.
23 changes: 23 additions & 0 deletions app/views/users/privacy/show.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
.section-head
%h2{ class: 'text-[1.4rem]' } Privacy API

%p.lead
Honor GDPR/CCPA requests via
%code request_user_data
and
%code delete_user_data
\. A valid Castle API secret is required.

.card.mt-4{ class: 'max-w-[640px]' }
= form_with url: users_privacy_path, method: :post do
.field
%label{ for: 'identifier' } identifier
= text_field_tag :identifier, params[:identifier].presence || current_user.email, class: 'input'
.field
%label{ for: 'identifier_type' } identifier type
= text_field_tag :identifier_type, params[:identifier_type].presence || '$email', class: 'input'
.btn-row
= button_tag 'Request user data', name: 'commit_action', value: 'request', class: 'btn btn-primary'
= button_tag 'Delete user data', name: 'commit_action', value: 'delete', class: 'btn'

= render 'users/shared/api_result', error: @error, result: @result
4 changes: 4 additions & 0 deletions app/views/users/shared/_api_result.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- if error
.alert.alert-danger.mt-4= error
- elsif result
%pre.card.mt-4{ class: 'overflow-auto whitespace-pre-wrap font-mono text-[0.82rem]' }= JSON.pretty_generate(result)
3 changes: 3 additions & 0 deletions config/locales/en/layouts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ en:
layouts:
application:
nav:
lists: Lists
privacy: Privacy
password_reset: Password reset
edit_password: Edit password
edit_profile: Edit profile
sign_out: Sign out
Expand Down
3 changes: 3 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
namespace :users do
resource :profile, only: %i[edit update]
resource :custom_event, only: %i[create]
resource :password_reset, only: %i[show create]
resource :lists, only: %i[show create]
resource :privacy, only: %i[show create], controller: 'privacy'
end

namespace :integrations do
Expand Down
62 changes: 62 additions & 0 deletions spec/controllers/users/lists_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# frozen_string_literal: true

RSpec.describe Users::ListsController do
render_views

describe 'GET #show' do
context 'when unauthenticated' do
before { get :show }

it { expect(response).to redirect_to new_user_session_path }
end

context 'when authenticated' do
with_user

before { get :show }

it { expect(response).to render_template(:show) }
it { expect(response).to have_http_status(:ok) }
end
end

describe 'POST #create' do
context 'when unauthenticated' do
before { post :create }

it { expect(response).to redirect_to new_user_session_path }
end

context 'when authenticated' do
with_user

before do
allow(controller.castle).to receive(:create_list).and_return(id: 'list_1')
allow(controller.castle).to receive(:get_all_lists).and_return([])
post :create, params: { name: 'demo-blocklist', color: '$red', primary_field: 'user.email' }
end

it { expect(response).to render_template(:show) }

it 'creates a list and fetches all lists' do
expect(controller.castle).to have_received(:create_list).with(
name: 'demo-blocklist', color: '$red', primary_field: 'user.email'
)
expect(controller.castle).to have_received(:get_all_lists)
end
end

context 'when Castle raises' do
with_user

before do
allow(controller.castle).to receive(:create_list).and_raise(Castle::Error)
post :create
end

it 'still renders without surfacing the error' do
expect(response).to render_template(:show)
end
end
end
end
Loading