From a1ba15c850cba4cdc20098b995f21e074a45167f Mon Sep 17 00:00:00 2001 From: Bartosz Date: Fri, 5 Jun 2026 18:40:56 +0200 Subject: [PATCH] Add Lists, Privacy, and password-reset demos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three new authenticated demo pages, reachable from the nav once signed in: - Lists API — create a list and fetch all lists (create_list / get_all_lists) - Privacy API — request or delete user data (request_user_data / delete_user_data) - Password reset — record a $password_reset event via the non-blocking log endpoint (the password is not actually changed) Bumps castle-rb to 9.x, which exposes the Privacy API client methods, and adds controller specs (with rendered views) for the new pages. --- Gemfile | 2 +- Gemfile.lock | 5 +- README.md | 13 +++- app/assets/builds/tailwind.css | 2 +- app/controllers/users/lists_controller.rb | 28 +++++++ .../users/password_resets_controller.rb | 30 +++++++ app/controllers/users/privacy_controller.rb | 31 ++++++++ app/views/layouts/application.html.haml | 3 + app/views/main/index.html.haml | 8 +- app/views/users/lists/show.html.haml | 25 ++++++ .../users/password_resets/show.html.haml | 36 +++++++++ app/views/users/privacy/show.html.haml | 23 ++++++ app/views/users/shared/_api_result.html.haml | 4 + config/locales/en/layouts.yml | 3 + config/routes.rb | 3 + .../users/lists_controller_spec.rb | 62 +++++++++++++++ .../users/password_resets_controller_spec.rb | 78 +++++++++++++++++++ .../users/privacy_controller_spec.rb | 75 ++++++++++++++++++ 18 files changed, 421 insertions(+), 10 deletions(-) create mode 100644 app/controllers/users/lists_controller.rb create mode 100644 app/controllers/users/password_resets_controller.rb create mode 100644 app/controllers/users/privacy_controller.rb create mode 100644 app/views/users/lists/show.html.haml create mode 100644 app/views/users/password_resets/show.html.haml create mode 100644 app/views/users/privacy/show.html.haml create mode 100644 app/views/users/shared/_api_result.html.haml create mode 100644 spec/controllers/users/lists_controller_spec.rb create mode 100644 spec/controllers/users/password_resets_controller_spec.rb create mode 100644 spec/controllers/users/privacy_controller_spec.rb diff --git a/Gemfile b/Gemfile index 739bd4c..5809d89 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/Gemfile.lock b/Gemfile.lock index 2f474ee..dad80fd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -309,7 +310,7 @@ PLATFORMS DEPENDENCIES bootsnap byebug - castle-rb (~> 8.1) + castle-rb (~> 9.1) devise (~> 5.0) dotenv-rails factory_bot_rails diff --git a/README.md b/README.md index 3550a0a..db1db09 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css index 4b67b79..724b2b4 100644 --- a/app/assets/builds/tailwind.css +++ b/app/assets/builds/tailwind.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}body{min-height:100vh;--tw-bg-opacity:1;background-color:rgb(246 248 252/var(--tw-bg-opacity,1));color:rgb(15 23 41/var(--tw-text-opacity,1));font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-size:15px;line-height:1.625;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-image:radial-gradient(1200px 600px at 80% -10%,rgba(54,94,237,.12),transparent 60%)}a,body{--tw-text-opacity:1}a{color:rgb(54 94 237/var(--tw-text-opacity,1));text-decoration-line:none}a:hover{text-decoration-line:underline}h1,h2,h3,h4{font-weight:600;line-height:1.25}p{margin-bottom:.75rem}code{border-radius:.25rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(238 242 251/var(--tw-bg-opacity,1));font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.86em;padding:.125rem .375rem}.relative{position:relative}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.block{display:block}.inline{display:inline}.table{display:table}.hidden{display:none}.w-full{width:100%}.list-disc{list-style-type:disc}.flex-col{flex-direction:column}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-3{gap:.75rem}.border{border-width:1px}.pl-5{padding-left:1.25rem}.text-center{text-align:center}.text-\[1\.2rem\]{font-size:1.2rem}.text-\[1\.3rem\]{font-size:1.3rem}.text-\[1\.4rem\]{font-size:1.4rem}.text-\[2rem\]{font-size:2rem}.text-muted{--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.navbar{border-bottom-width:1px;flex-wrap:wrap;gap:1.5rem;position:sticky;top:0;z-index:50;--tw-border-opacity:1;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);background:hsla(0,0%,100%,.8);border-color:rgb(221 227 238/var(--tw-border-opacity,1));padding:.875rem 1.5rem}.brand,.navbar{align-items:center;display:flex}.brand{font-size:1.05rem;font-weight:700;gap:.5rem;--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1))}.brand:hover{text-decoration-line:none}.brand-logo{flex-shrink:0;height:1.4rem;width:1.4rem;--tw-text-opacity:1;color:rgb(54 94 237/var(--tw-text-opacity,1));filter:drop-shadow(0 0 8px rgba(54,94,237,.35))}.brand-logo-lg{height:3rem;width:3rem}.nav-links{align-items:center;display:flex;flex-wrap:wrap;gap:1.25rem;margin-left:auto}.nav-links a{font-size:.92rem;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.nav-links a:hover{--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1));text-decoration-line:none}.nav-links form{display:inline;margin:0}.nav-links form button{background-color:transparent;border-width:0;cursor:pointer;font-size:.92rem;padding:0;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.nav-links form button:hover{--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1))}.tag{background-color:rgba(54,94,237,.1);border-color:rgba(54,94,237,.4);border-radius:9999px;border-width:1px;font-size:.75rem;font-weight:600;line-height:1rem;padding:.125rem .5rem;--tw-text-opacity:1;color:rgb(54 94 237/var(--tw-text-opacity,1))}.container-page{margin-left:auto;margin-right:auto;max-width:1120px;padding:2rem 1.5rem 4rem}.container-narrow{margin-left:auto;margin-right:auto;max-width:420px;padding:4rem 1.5rem;width:100%}.card{border-radius:14px;border-width:1px;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1));padding:1.5rem;--tw-shadow:0 1px 3px rgba(16,24,40,.06),0 8px 24px rgba(16,24,40,.06);--tw-shadow-colored:0 1px 3px var(--tw-shadow-color),0 8px 24px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.eyebrow{font-size:.75rem;font-weight:700;letter-spacing:.05em;line-height:1rem;margin-bottom:.375rem;text-transform:uppercase;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.hero{margin-bottom:2rem;padding:2rem}.feature,.hero{border-radius:14px;border-width:1px;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.feature{display:block;padding:1.25rem;text-align:left;transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.feature:hover{--tw-translate-y:-0.125rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-border-opacity:1;border-color:rgb(54 94 237/var(--tw-border-opacity,1));text-decoration-line:none}.section-head{border-bottom-width:1px;margin-bottom:.75rem;margin-top:2rem;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));padding-bottom:.5rem}.prose-list>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.field{margin-bottom:.875rem}.field label{display:block;font-size:.82rem;font-weight:600;margin-bottom:.375rem;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.input,input[type=email],input[type=password],input[type=text]{border-radius:9px;border-width:1px;width:100%;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(238 242 249/var(--tw-bg-opacity,1));font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-size:.95rem;padding:.625rem .75rem;--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1));transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.input:focus,input[type=email]:focus,input[type=password]:focus,input[type=text]:focus{--tw-border-opacity:1;border-color:rgb(54 94 237/var(--tw-border-opacity,1));box-shadow:0 0 0 3px rgba(54,94,237,.14);outline:2px solid transparent;outline-offset:2px}.btn{border-radius:9px;border-width:1px;cursor:pointer;display:inline-block;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(238 242 251/var(--tw-bg-opacity,1));font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-size:.92rem;font-weight:600;padding:.625rem 1rem;text-align:center;--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1));text-decoration-line:none;transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.btn:hover{--tw-border-opacity:1;border-color:rgb(54 94 237/var(--tw-border-opacity,1));text-decoration-line:none}.btn:active{--tw-translate-y:1px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.btn-primary{--tw-border-opacity:1;border-color:rgb(54 94 237/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(54 94 237/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.btn-primary:hover{--tw-bg-opacity:1;background-color:rgb(42 78 209/var(--tw-bg-opacity,1))}.btn-alt,.btn-ghost{background-color:transparent}.btn-danger{background-color:rgba(220,38,38,.1);border-color:rgba(220,38,38,.5);--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.btn-row{display:flex;flex-wrap:wrap;gap:.625rem;margin-top:1rem}.alert{align-items:center;border-radius:9px;border-width:1px;display:flex;font-size:.92rem;gap:1rem;justify-content:space-between;margin-bottom:1rem;padding:.75rem 1rem}.alert-success{background-color:rgba(22,163,74,.1);border-color:rgba(22,163,74,.4)}.alert-danger,.alert-success{--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1))}.alert-danger{background-color:rgba(220,38,38,.1);border-color:rgba(220,38,38,.4)}.alert .btn-close{background-color:transparent;border-width:0;cursor:pointer;font-size:1.125rem;line-height:1.75rem;line-height:1;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.alert .btn-close:hover{--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1))}table.table{border-collapse:collapse;border-radius:9px;font-size:.9rem;overflow:hidden;width:100%}table.table th{border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(238 242 251/var(--tw-bg-opacity,1));font-size:.78rem;font-weight:700;letter-spacing:.025em;padding:.5rem .75rem;text-align:left;text-transform:uppercase;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}table.table td{border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(233 237 245/var(--tw-border-opacity,1));padding:.5rem .75rem;vertical-align:top}.lead{font-size:1.1rem;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.form-actions{margin-top:1.25rem}.error,.invalid-feedback{color:rgb(220 38 38/var(--tw-text-opacity,1))}.error,.hint,.invalid-feedback{display:block;font-size:.8rem;margin-top:.25rem;--tw-text-opacity:1}.hint{color:rgb(91 102 120/var(--tw-text-opacity,1))}.field_with_errors{display:contents} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}body{min-height:100vh;--tw-bg-opacity:1;background-color:rgb(246 248 252/var(--tw-bg-opacity,1));color:rgb(15 23 41/var(--tw-text-opacity,1));font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-size:15px;line-height:1.625;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-image:radial-gradient(1200px 600px at 80% -10%,rgba(54,94,237,.12),transparent 60%)}a,body{--tw-text-opacity:1}a{color:rgb(54 94 237/var(--tw-text-opacity,1));text-decoration-line:none}a:hover{text-decoration-line:underline}h1,h2,h3,h4{font-weight:600;line-height:1.25}p{margin-bottom:.75rem}code{border-radius:.25rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(238 242 251/var(--tw-bg-opacity,1));font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.86em;padding:.125rem .375rem}.relative{position:relative}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.block{display:block}.inline{display:inline}.table{display:table}.hidden{display:none}.w-full{width:100%}.max-w-\[640px\]{max-width:640px}.list-disc{list-style-type:disc}.flex-col{flex-direction:column}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-3{gap:.75rem}.overflow-auto{overflow:auto}.whitespace-pre-wrap{white-space:pre-wrap}.border{border-width:1px}.pl-5{padding-left:1.25rem}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.text-\[0\.82rem\]{font-size:.82rem}.text-\[0\.9rem\]{font-size:.9rem}.text-\[1\.2rem\]{font-size:1.2rem}.text-\[1\.3rem\]{font-size:1.3rem}.text-\[1\.4rem\]{font-size:1.4rem}.text-\[2rem\]{font-size:2rem}.text-muted{--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.navbar{border-bottom-width:1px;flex-wrap:wrap;gap:1.5rem;position:sticky;top:0;z-index:50;--tw-border-opacity:1;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);background:hsla(0,0%,100%,.8);border-color:rgb(221 227 238/var(--tw-border-opacity,1));padding:.875rem 1.5rem}.brand,.navbar{align-items:center;display:flex}.brand{font-size:1.05rem;font-weight:700;gap:.5rem;--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1))}.brand:hover{text-decoration-line:none}.brand-logo{flex-shrink:0;height:1.4rem;width:1.4rem;--tw-text-opacity:1;color:rgb(54 94 237/var(--tw-text-opacity,1));filter:drop-shadow(0 0 8px rgba(54,94,237,.35))}.brand-logo-lg{height:3rem;width:3rem}.nav-links{align-items:center;display:flex;flex-wrap:wrap;gap:1.25rem;margin-left:auto}.nav-links a{font-size:.92rem;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.nav-links a:hover{--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1));text-decoration-line:none}.nav-links form{display:inline;margin:0}.nav-links form button{background-color:transparent;border-width:0;cursor:pointer;font-size:.92rem;padding:0;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.nav-links form button:hover{--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1))}.tag{background-color:rgba(54,94,237,.1);border-color:rgba(54,94,237,.4);border-radius:9999px;border-width:1px;font-size:.75rem;font-weight:600;line-height:1rem;padding:.125rem .5rem;--tw-text-opacity:1;color:rgb(54 94 237/var(--tw-text-opacity,1))}.container-page{margin-left:auto;margin-right:auto;max-width:1120px;padding:2rem 1.5rem 4rem}.container-narrow{margin-left:auto;margin-right:auto;max-width:420px;padding:4rem 1.5rem;width:100%}.card{border-radius:14px;border-width:1px;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1));padding:1.5rem;--tw-shadow:0 1px 3px rgba(16,24,40,.06),0 8px 24px rgba(16,24,40,.06);--tw-shadow-colored:0 1px 3px var(--tw-shadow-color),0 8px 24px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.eyebrow{font-size:.75rem;font-weight:700;letter-spacing:.05em;line-height:1rem;margin-bottom:.375rem;text-transform:uppercase;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.hero{margin-bottom:2rem;padding:2rem}.feature,.hero{border-radius:14px;border-width:1px;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.feature{display:block;padding:1.25rem;text-align:left;transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.feature:hover{--tw-translate-y:-0.125rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-border-opacity:1;border-color:rgb(54 94 237/var(--tw-border-opacity,1));text-decoration-line:none}.section-head{border-bottom-width:1px;margin-bottom:.75rem;margin-top:2rem;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));padding-bottom:.5rem}.prose-list>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.field{margin-bottom:.875rem}.field label{display:block;font-size:.82rem;font-weight:600;margin-bottom:.375rem;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.input,input[type=email],input[type=password],input[type=text]{border-radius:9px;border-width:1px;width:100%;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(238 242 249/var(--tw-bg-opacity,1));font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-size:.95rem;padding:.625rem .75rem;--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1));transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.input:focus,input[type=email]:focus,input[type=password]:focus,input[type=text]:focus{--tw-border-opacity:1;border-color:rgb(54 94 237/var(--tw-border-opacity,1));box-shadow:0 0 0 3px rgba(54,94,237,.14);outline:2px solid transparent;outline-offset:2px}.btn{border-radius:9px;border-width:1px;cursor:pointer;display:inline-block;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(238 242 251/var(--tw-bg-opacity,1));font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-size:.92rem;font-weight:600;padding:.625rem 1rem;text-align:center;--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1));text-decoration-line:none;transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.btn:hover{--tw-border-opacity:1;border-color:rgb(54 94 237/var(--tw-border-opacity,1));text-decoration-line:none}.btn:active{--tw-translate-y:1px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.btn-primary{--tw-border-opacity:1;border-color:rgb(54 94 237/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(54 94 237/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.btn-primary:hover{--tw-bg-opacity:1;background-color:rgb(42 78 209/var(--tw-bg-opacity,1))}.btn-alt,.btn-ghost{background-color:transparent}.btn-danger{background-color:rgba(220,38,38,.1);border-color:rgba(220,38,38,.5);--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.btn-row{display:flex;flex-wrap:wrap;gap:.625rem;margin-top:1rem}.alert{align-items:center;border-radius:9px;border-width:1px;display:flex;font-size:.92rem;gap:1rem;justify-content:space-between;margin-bottom:1rem;padding:.75rem 1rem}.alert-success{background-color:rgba(22,163,74,.1);border-color:rgba(22,163,74,.4)}.alert-danger,.alert-success{--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1))}.alert-danger{background-color:rgba(220,38,38,.1);border-color:rgba(220,38,38,.4)}.alert .btn-close{background-color:transparent;border-width:0;cursor:pointer;font-size:1.125rem;line-height:1.75rem;line-height:1;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.alert .btn-close:hover{--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1))}table.table{border-collapse:collapse;border-radius:9px;font-size:.9rem;overflow:hidden;width:100%}table.table th{border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(238 242 251/var(--tw-bg-opacity,1));font-size:.78rem;font-weight:700;letter-spacing:.025em;padding:.5rem .75rem;text-align:left;text-transform:uppercase;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}table.table td{border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(233 237 245/var(--tw-border-opacity,1));padding:.5rem .75rem;vertical-align:top}.lead{font-size:1.1rem;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.form-actions{margin-top:1.25rem}.error,.invalid-feedback{color:rgb(220 38 38/var(--tw-text-opacity,1))}.error,.hint,.invalid-feedback{display:block;font-size:.8rem;margin-top:.25rem;--tw-text-opacity:1}.hint{color:rgb(91 102 120/var(--tw-text-opacity,1))}.field_with_errors{display:contents} \ No newline at end of file diff --git a/app/controllers/users/lists_controller.rb b/app/controllers/users/lists_controller.rb new file mode 100644 index 0000000..d88fa42 --- /dev/null +++ b/app/controllers/users/lists_controller.rb @@ -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 diff --git a/app/controllers/users/password_resets_controller.rb b/app/controllers/users/password_resets_controller.rb new file mode 100644 index 0000000..cf9885a --- /dev/null +++ b/app/controllers/users/password_resets_controller.rb @@ -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 diff --git a/app/controllers/users/privacy_controller.rb b/app/controllers/users/privacy_controller.rb new file mode 100644 index 0000000..38a3f41 --- /dev/null +++ b/app/controllers/users/privacy_controller.rb @@ -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 diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 4ece885..2e03c02 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -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 } } diff --git a/app/views/main/index.html.haml b/app/views/main/index.html.haml index ce064b7..3f24349 100644 --- a/app/views/main/index.html.haml +++ b/app/views/main/index.html.haml @@ -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 @@ -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 — recorded with the non-blocking %code log endpoint. + %li + %strong Lists & Privacy APIs + — manage allow/block lists and honor GDPR/CCPA data requests + (available from the nav once signed in). %li %strong Webhooks — incoming Castle webhooks are signature-verified and listed under diff --git a/app/views/users/lists/show.html.haml b/app/views/users/lists/show.html.haml new file mode 100644 index 0000000..4834a84 --- /dev/null +++ b/app/views/users/lists/show.html.haml @@ -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 diff --git a/app/views/users/password_resets/show.html.haml b/app/views/users/password_resets/show.html.haml new file mode 100644 index 0000000..f8afa49 --- /dev/null +++ b/app/views/users/password_resets/show.html.haml @@ -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. diff --git a/app/views/users/privacy/show.html.haml b/app/views/users/privacy/show.html.haml new file mode 100644 index 0000000..fafb4a4 --- /dev/null +++ b/app/views/users/privacy/show.html.haml @@ -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 diff --git a/app/views/users/shared/_api_result.html.haml b/app/views/users/shared/_api_result.html.haml new file mode 100644 index 0000000..9d50eae --- /dev/null +++ b/app/views/users/shared/_api_result.html.haml @@ -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) diff --git a/config/locales/en/layouts.yml b/config/locales/en/layouts.yml index cea0c2b..f9f7cd3 100644 --- a/config/locales/en/layouts.yml +++ b/config/locales/en/layouts.yml @@ -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 diff --git a/config/routes.rb b/config/routes.rb index afe05ab..d9c98cc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/spec/controllers/users/lists_controller_spec.rb b/spec/controllers/users/lists_controller_spec.rb new file mode 100644 index 0000000..24b76e4 --- /dev/null +++ b/spec/controllers/users/lists_controller_spec.rb @@ -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 diff --git a/spec/controllers/users/password_resets_controller_spec.rb b/spec/controllers/users/password_resets_controller_spec.rb new file mode 100644 index 0000000..c52ed32 --- /dev/null +++ b/spec/controllers/users/password_resets_controller_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +RSpec.describe Users::PasswordResetsController 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 a new password is supplied' do + with_user + + before do + allow(controller.castle).to receive(:log) + post :create, params: { password: 'a-brand-new-password' } + end + + it { expect(response).to render_template(:show) } + + it 'logs $password_reset / $succeeded' do + expect(controller.castle).to have_received(:log).with( + type: '$password_reset', + status: '$succeeded', + request_token: nil, + user: { id: user.id, email: user.email } + ) + end + end + + context 'when the current password is reused' do + let(:user) { create(:user, password: 'current-password-1') } + + before do + @request.env['devise.mapping'] = Devise.mappings[:user] + sign_in user + allow(controller.castle).to receive(:log) + post :create, params: { password: 'current-password-1' } + end + + it 'logs $password_reset / $failed' do + expect(controller.castle).to have_received(:log).with(hash_including(status: '$failed')) + end + end + + context 'when Castle raises' do + with_user + + before do + allow(controller.castle).to receive(:log).and_raise(Castle::Error) + post :create, params: { password: 'a-brand-new-password' } + end + + it 'still renders without surfacing the error' do + expect(response).to render_template(:show) + end + end + end +end diff --git a/spec/controllers/users/privacy_controller_spec.rb b/spec/controllers/users/privacy_controller_spec.rb new file mode 100644 index 0000000..a4a3c39 --- /dev/null +++ b/spec/controllers/users/privacy_controller_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +RSpec.describe Users::PrivacyController 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 requesting user data' do + with_user + + before do + allow(controller.castle).to receive(:request_user_data).and_return(status: 'pending') + post :create, params: { identifier: 'jane@example.com', identifier_type: '$email' } + end + + it { expect(response).to render_template(:show) } + + it 'calls the request-user-data endpoint' do + expect(controller.castle).to have_received(:request_user_data).with( + identifier: 'jane@example.com', identifier_type: '$email' + ) + end + end + + context 'when deleting user data' do + with_user + + before do + allow(controller.castle).to receive(:delete_user_data).and_return(status: 'pending') + post :create, params: { identifier: 'jane@example.com', identifier_type: '$email', commit_action: 'delete' } + end + + it 'calls the delete-user-data endpoint' do + expect(controller.castle).to have_received(:delete_user_data).with( + identifier: 'jane@example.com', identifier_type: '$email' + ) + end + end + + context 'when Castle raises' do + with_user + + before do + allow(controller.castle).to receive(:request_user_data).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