tsykvas_rails_template 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +200 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +589 -0
- data/Rakefile +17 -0
- data/lib/generators/tsykvas_rails_template/companions/companions_generator.rb +273 -0
- data/lib/generators/tsykvas_rails_template/concept/concept_generator.rb +145 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/component/edit.html.slim.tt +5 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/component/edit.rb.tt +11 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/component/index.html.slim.tt +5 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/component/index.rb.tt +11 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/component/new.html.slim.tt +5 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/component/new.rb.tt +11 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/component/show.html.slim.tt +4 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/component/show.rb.tt +11 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/controller.rb.tt +45 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/operation/create.rb.tt +31 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/operation/destroy.rb.tt +13 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/operation/edit.rb.tt +10 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/operation/index.rb.tt +9 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/operation/new.rb.tt +10 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/operation/show.rb.tt +10 -0
- data/lib/generators/tsykvas_rails_template/concept/templates/operation/update.rb.tt +31 -0
- data/lib/generators/tsykvas_rails_template/install/bootstrap_installer.rb +225 -0
- data/lib/generators/tsykvas_rails_template/install/install_generator.rb +298 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/agents/buddy.md +157 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/agents/code-reviewer.md +117 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/agents/security-reviewer.md +113 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/agents/tech-lead.md +150 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/check.md +51 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/code-review.md +60 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/docs-create.md +102 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/pr-review.md +81 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/pushit.md +160 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/refactor.md +132 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/task-sum.md +47 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/tests.md +67 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/tsykvas-claude.md +262 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/update-docs.md +78 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/update-rules.md +102 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/commands/update-tests.md +135 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/architecture.md +315 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/authentication.md +96 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/background-jobs.md +135 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/code-style.md +101 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/commands.md +34 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/companions.md +128 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/concepts-refactoring.md +194 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/database.md +135 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/deployment.md +138 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/design-system.md +322 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/documentation.md +89 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/forms.md +174 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/i18n.md +165 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/routing-and-namespaces.md +114 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/security.md +122 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/stimulus-controllers.md +166 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/testing-examples.md +180 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/testing.md +117 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/tsykvas_rails_template.md +280 -0
- data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/ui-components.md +196 -0
- data/lib/generators/tsykvas_rails_template/install/templates/CLAUDE.md.tt +81 -0
- data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/base/component/base.rb +6 -0
- data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/base/operation/base.rb +124 -0
- data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/base/operation/result.rb +56 -0
- data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/home/component/index.html.slim +49 -0
- data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/home/component/index.rb +11 -0
- data/lib/generators/tsykvas_rails_template/install/templates/app/concepts/home/operation/index.rb +17 -0
- data/lib/generators/tsykvas_rails_template/install/templates/app/controllers/concerns/operations_methods.rb +148 -0
- data/lib/generators/tsykvas_rails_template/install/templates/app/controllers/home_controller.rb +10 -0
- data/lib/generators/tsykvas_rails_template/install/templates/app/policies/application_policy.rb +33 -0
- data/lib/generators/tsykvas_rails_template/install/templates/app/policies/home_policy.rb +8 -0
- data/lib/tasks/tsykvas.rake +11 -0
- data/lib/tsykvas_rails_template/probe.rb +236 -0
- data/lib/tsykvas_rails_template/railtie.rb +13 -0
- data/lib/tsykvas_rails_template/version.rb +5 -0
- data/lib/tsykvas_rails_template.rb +18 -0
- metadata +183 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# I18n
|
|
2
|
+
|
|
3
|
+
Default locale and fallbacks are configured in `config/application.rb`. Time zone is configured there too. All UI strings must come from locale files — no inline literals in components or operations.
|
|
4
|
+
|
|
5
|
+
## Locale files
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
config/locales/
|
|
9
|
+
en.yml # Application strings (one file per locale you ship)
|
|
10
|
+
devise.en.yml # Devise built-in strings (sign-up, errors, mailer, ...) — one per locale
|
|
11
|
+
simple_form.en.yml # simple_form built-in strings (required mark, error notification, ...) — one per locale
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
**Rule:** every key must exist in every locale file. They are mirrors. CI does not enforce this — be diligent.
|
|
15
|
+
|
|
16
|
+
When you add a Devise- or simple_form-specific override, put it in the matching `<gem>.<locale>.yml`. Don't mix them with app strings in your main locale files.
|
|
17
|
+
|
|
18
|
+
## The full-key rule
|
|
19
|
+
|
|
20
|
+
Always reference the full I18n key — never the relative `t('.foo')` shorthand:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# ✅ good
|
|
24
|
+
I18n.t('admin.users.index.title')
|
|
25
|
+
I18n.t('crm.property.edit.submit')
|
|
26
|
+
|
|
27
|
+
# ❌ bad
|
|
28
|
+
t('.title') # only resolves with conventional view paths; ViewComponents don't follow them
|
|
29
|
+
t('admin.users.title') # under-qualified — collisions across domains
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
This applies in operations, components, slim templates, controllers — everywhere. The reason: ViewComponent's lookup path differs from regular Rails views, so `t('.relative_key')` is unreliable.
|
|
33
|
+
|
|
34
|
+
## Key structure
|
|
35
|
+
|
|
36
|
+
Top-level: domain (`admin`, `crm`, `public`, `forms`, `notices`, `alerts`, `authorization`, `simple_form`).
|
|
37
|
+
Below that: feature → action → label.
|
|
38
|
+
|
|
39
|
+
```yaml
|
|
40
|
+
en:
|
|
41
|
+
admin:
|
|
42
|
+
users:
|
|
43
|
+
index:
|
|
44
|
+
title: "Users"
|
|
45
|
+
table:
|
|
46
|
+
id: "ID"
|
|
47
|
+
email: "Email"
|
|
48
|
+
role: "Role"
|
|
49
|
+
delete:
|
|
50
|
+
confirm: "Are you sure?"
|
|
51
|
+
|
|
52
|
+
crm:
|
|
53
|
+
property:
|
|
54
|
+
edit:
|
|
55
|
+
title: "Edit property"
|
|
56
|
+
name: "Name"
|
|
57
|
+
name_placeholder: "Enter a name..."
|
|
58
|
+
submit: "Save"
|
|
59
|
+
|
|
60
|
+
notices:
|
|
61
|
+
created: "Created"
|
|
62
|
+
updated: "Updated"
|
|
63
|
+
deleted: "Deleted"
|
|
64
|
+
|
|
65
|
+
alerts:
|
|
66
|
+
failed: "Failed"
|
|
67
|
+
resource_not_found: "Record not found"
|
|
68
|
+
|
|
69
|
+
authorization:
|
|
70
|
+
admin_access_denied: "Access denied"
|
|
71
|
+
crm_access_denied: "CRM access denied"
|
|
72
|
+
action_access_denied: "Action not allowed"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## simple_form conventions
|
|
76
|
+
|
|
77
|
+
`simple_form` reads labels, placeholders, and hints by convention:
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
en:
|
|
81
|
+
simple_form:
|
|
82
|
+
labels:
|
|
83
|
+
property:
|
|
84
|
+
name: "Name"
|
|
85
|
+
placeholders:
|
|
86
|
+
property:
|
|
87
|
+
name: "Enter a name..."
|
|
88
|
+
hints:
|
|
89
|
+
property:
|
|
90
|
+
name: "Public name."
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
With these in place, `f.input :name` auto-populates label + placeholder + hint. **Prefer this over inline `f.input :name, label: ..., placeholder: ...`** — see `forms.md`.
|
|
94
|
+
|
|
95
|
+
## Pluralization
|
|
96
|
+
|
|
97
|
+
```yaml
|
|
98
|
+
en:
|
|
99
|
+
reports:
|
|
100
|
+
count:
|
|
101
|
+
one: "%{count} report"
|
|
102
|
+
other: "%{count} reports"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Use `I18n.t('reports.count', count: n)`. For locales with multiple plural forms (e.g. `one`/`few`/`many`/`other` in Slavic languages), include each form your target locales require — see the [Rails I18n plural rules](https://guides.rubyonrails.org/i18n.html#pluralization).
|
|
106
|
+
|
|
107
|
+
## Time zone & date formatting
|
|
108
|
+
|
|
109
|
+
- App-wide time zone: set in `config/application.rb`.
|
|
110
|
+
- Always use `Time.zone.parse(...)`, `Time.zone.now`, `Date.current` — never `DateTime.parse` or `Time.now`.
|
|
111
|
+
- For display, prefer `Base::Component::Table::Table#format_date`.
|
|
112
|
+
|
|
113
|
+
For custom date formats, register them in the locale file:
|
|
114
|
+
|
|
115
|
+
```yaml
|
|
116
|
+
en:
|
|
117
|
+
date:
|
|
118
|
+
formats:
|
|
119
|
+
default: "%Y-%m-%d"
|
|
120
|
+
long: "%B %-d, %Y"
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Then `I18n.l(date, format: :long)`.
|
|
124
|
+
|
|
125
|
+
## Authorization-denied messages
|
|
126
|
+
|
|
127
|
+
`ApplicationController#user_not_authorized` reads three keys:
|
|
128
|
+
|
|
129
|
+
- `authorization.admin_access_denied`
|
|
130
|
+
- `authorization.crm_access_denied`
|
|
131
|
+
- `authorization.action_access_denied`
|
|
132
|
+
|
|
133
|
+
Every locale you ship must define all three.
|
|
134
|
+
|
|
135
|
+
## UI / design-system strings
|
|
136
|
+
|
|
137
|
+
Brand- or design-system strings (sidebar section titles, taglines, navigational labels) belong in their own top-level namespace so the design layer stays decoupled from feature copy:
|
|
138
|
+
|
|
139
|
+
```yaml
|
|
140
|
+
ui:
|
|
141
|
+
sections:
|
|
142
|
+
workspace: "..."
|
|
143
|
+
account: "..."
|
|
144
|
+
administration: "..."
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Reference via `I18n.t('ui.sections.workspace')`. Typographic constants that should never be translated (e.g. brand year markers) belong here too — keep them identical across all locales.
|
|
148
|
+
|
|
149
|
+
See [`design-system.md`](design-system.md) for the visual context behind these strings.
|
|
150
|
+
|
|
151
|
+
## Adding a new translation
|
|
152
|
+
|
|
153
|
+
1. Add the key to your default-locale YAML under the right namespace.
|
|
154
|
+
2. Mirror it in every other locale you ship.
|
|
155
|
+
3. Reference it via `I18n.t('full.key')`.
|
|
156
|
+
4. If it's a `simple_form` label/placeholder/hint, follow the simple_form convention so it's picked up automatically.
|
|
157
|
+
|
|
158
|
+
## Anti-patterns
|
|
159
|
+
|
|
160
|
+
- ❌ `t('.title')` — relative keys break in ViewComponent.
|
|
161
|
+
- ❌ Inline strings in components or slim templates.
|
|
162
|
+
- ❌ Adding a key only to one locale — fallback must work.
|
|
163
|
+
- ❌ Using `DateTime.parse` / `Time.now` instead of `Time.zone.*`.
|
|
164
|
+
- ❌ Mixing Devise/simple_form strings into your app locale files.
|
|
165
|
+
- ❌ Deep nesting beyond 4 levels — split into a sibling top-level key instead.
|
data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/routing-and-namespaces.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Routing & Namespaces
|
|
2
|
+
|
|
3
|
+
This app is split into three top-level domains. Each has its own URL namespace, layout, base controller, and base policy. Read this before adding a new controller — placement is not optional.
|
|
4
|
+
|
|
5
|
+
## Domain → namespace map
|
|
6
|
+
|
|
7
|
+
| Domain | URL prefix | Layout | Base controller | Base policy | Concepts root |
|
|
8
|
+
|---|---|---|---|---|---|
|
|
9
|
+
| **Admin** (administrators) | `/admin/...` | `app/views/layouts/admin.slim` | `Admin::BaseController` | `Admin::BasePolicy` (`user.admin?`) | `app/concepts/admin/` |
|
|
10
|
+
| **CRM** (business users) | `/crm/...` | `app/views/layouts/crm.slim` | `Crm::BaseController` | `Crm::BasePolicy` (admin / owner / employee / manager) | `app/concepts/crm/` |
|
|
11
|
+
| **Screener** (end consumers) | `/` (root) and `/screener/...` | `app/views/layouts/screener.slim` | `Screener::BaseController` | `Screener::BasePolicy` | `app/concepts/screener/` (not yet created — first feature in this domain creates it) |
|
|
12
|
+
|
|
13
|
+
Each `BaseController` runs a `before_action` that instantiates its `BasePolicy` with `current_user, nil` and raises `Pundit::NotAuthorizedError` if `policy.index?` is false. `ApplicationController#user_not_authorized` then dispatches the redirect:
|
|
14
|
+
|
|
15
|
+
| Policy class on the exception | Where the user is sent |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `Admin::BasePolicy` | `crm_root_path` if owner/employee/manager, else `root_path` |
|
|
18
|
+
| `Crm::BasePolicy` | `crm_root_path` for crm-eligible users, else `root_path` |
|
|
19
|
+
| `Screener::BasePolicy` | `crm_root_path` |
|
|
20
|
+
| anything else | `redirect_back(fallback_location: root_path)` |
|
|
21
|
+
|
|
22
|
+
The redirect hinges on the **policy class name**, so keep `<Domain>Policy < <Domain>::BasePolicy` intact when you add new policies.
|
|
23
|
+
|
|
24
|
+
## Adding a controller
|
|
25
|
+
|
|
26
|
+
1. Inherit from the matching base controller — never from `ApplicationController` directly.
|
|
27
|
+
2. Mount under the matching `namespace` block in `config/routes.rb`.
|
|
28
|
+
3. Place operations + components under `app/concepts/<namespace>/<feature>/`.
|
|
29
|
+
4. Each action is a one-liner: `endpoint Op, Component` (see `concepts-refactoring.md`).
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# config/routes.rb
|
|
33
|
+
namespace :crm do
|
|
34
|
+
resources :reports, only: [:index, :show]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# app/controllers/crm/reports_controller.rb
|
|
38
|
+
class Crm::ReportsController < Crm::BaseController
|
|
39
|
+
def index
|
|
40
|
+
endpoint Crm::Report::Operation::Index, Crm::Report::Component::Index
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Custom (non-`resources`) routes
|
|
46
|
+
|
|
47
|
+
Some flows don't map cleanly to REST — for example `Crm::PropertyController#edit` uses a singular path with no `id` (an owner edits *their* property):
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
namespace :crm do
|
|
51
|
+
get "property/edit", to: "property#edit", as: :edit_property
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Use a custom path when:
|
|
56
|
+
- the resource is implicitly the current user's (no id needed), OR
|
|
57
|
+
- the action name doesn't fit `index/show/new/create/edit/update/destroy` (e.g. `archive_property`, `confirm`, `archive`).
|
|
58
|
+
|
|
59
|
+
`endpoint` handles non-standard action names automatically: if the operation sets `self.redirect_path`, it redirects; otherwise it renders the component.
|
|
60
|
+
|
|
61
|
+
## How `endpoint` derives the redirect target
|
|
62
|
+
|
|
63
|
+
After a successful `create` / `update` / `destroy` (or any action name containing `destroy`), `endpoint` redirects to:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
result.redirect_path || public_send("#{controller_name}_path")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Two consequences:
|
|
70
|
+
|
|
71
|
+
1. **Set `self.redirect_path` explicitly** for non-trivial cases. The fallback only works if a route helper exists with the exact name `<controller_name>_path`.
|
|
72
|
+
2. **Controller name matters.** `Crm::propertiesController` falls back to `properties_path`, NOT `crm_properties_path`. If your controller's pluralized name doesn't match a route helper, you must set `self.redirect_path`.
|
|
73
|
+
|
|
74
|
+
## Named route helpers (cheat-sheet)
|
|
75
|
+
|
|
76
|
+
- `root_path` → `Screener::HomeController#index` (public landing)
|
|
77
|
+
- `screener_root_path` → same controller, namespaced URL
|
|
78
|
+
- `admin_root_path` → `Admin::DashboardController#index`
|
|
79
|
+
- `crm_root_path` → `Crm::DashboardController#index`
|
|
80
|
+
- `crm_edit_property_path` → `Crm::PropertyController#edit`
|
|
81
|
+
- `new_user_session_path` / `destroy_user_session_path` — Devise sign-in/out
|
|
82
|
+
- `new_user_registration_path` / `user_registration_path` — Devise sign-up (uses `Users::RegistrationsController`)
|
|
83
|
+
- `rails_health_check_path` → `/up` (uptime probes)
|
|
84
|
+
|
|
85
|
+
When linking from an operation, prefer route helpers via `Rails.application.routes.url_helpers`:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
self.redirect_path = Rails.application.routes.url_helpers.crm_root_path
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Devise routes
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
devise_for :users, controllers: { registrations: "users/registrations" }
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Only `registrations` is overridden — see `authentication.md` for the custom sign-up flow. Other Devise controllers (sessions, passwords) use defaults.
|
|
98
|
+
|
|
99
|
+
## What lives where
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
config/routes.rb # All routes — keep namespaced
|
|
103
|
+
app/controllers/
|
|
104
|
+
application_controller.rb # Pundit + OperationsMethods + redirect dispatch
|
|
105
|
+
admin/base_controller.rb # layout + Admin::BasePolicy gate
|
|
106
|
+
crm/base_controller.rb # layout + Crm::BasePolicy gate
|
|
107
|
+
screener/base_controller.rb # layout + Screener::BasePolicy gate
|
|
108
|
+
users/registrations_controller.rb # custom Devise sign-up
|
|
109
|
+
app/policies/
|
|
110
|
+
application_policy.rb # Pundit baseline
|
|
111
|
+
admin/base_policy.rb
|
|
112
|
+
crm/base_policy.rb
|
|
113
|
+
screener/base_policy.rb
|
|
114
|
+
```
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
Defaults Rails 8.1 ships with, plus a few project-specific settings. Run `bin/brakeman --no-pager` before opening a PR — it's part of CI.
|
|
4
|
+
|
|
5
|
+
## Authorization
|
|
6
|
+
|
|
7
|
+
**Pundit** is mandatory in every operation. `OperationsMethods#check_authorization_is_called` enforces this — any operation that doesn't call one of `authorize!` / `policy_scope` / `skip_authorize` / `authorize_and_save!` raises `Pundit::AuthorizationNotPerformedError` after the operation runs.
|
|
8
|
+
|
|
9
|
+
See `architecture.md` for the full Pundit setup. Per-domain base policies (`Admin::BasePolicy`, `Crm::BasePolicy`, `Screener::BasePolicy`) gate entire namespaces via the `BaseController#before_action`.
|
|
10
|
+
|
|
11
|
+
## Parameter filtering
|
|
12
|
+
|
|
13
|
+
`config/initializers/filter_parameter_logging.rb`:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
Rails.application.config.filter_parameters += [
|
|
17
|
+
:passw, :email, :secret, :token, :_key, :crypt, :salt,
|
|
18
|
+
:certificate, :otp, :ssn, :cvv, :cvc
|
|
19
|
+
]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`:passw` is a partial match (covers `password`, `password_confirmation`, ...). `:email` is filtered out of logs to satisfy data-protection norms — be aware that even server logs won't show emails.
|
|
23
|
+
|
|
24
|
+
When you add a new sensitive attribute, add the matching token here.
|
|
25
|
+
|
|
26
|
+
## Content Security Policy
|
|
27
|
+
|
|
28
|
+
`config/initializers/content_security_policy.rb` is currently **commented out** (Rails default). When you enable it for production:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
Rails.application.configure do
|
|
32
|
+
config.content_security_policy do |policy|
|
|
33
|
+
policy.default_src :self, :https
|
|
34
|
+
policy.script_src :self, :https, :unsafe_inline # only if needed for importmap nonces
|
|
35
|
+
policy.style_src :self, :https
|
|
36
|
+
policy.img_src :self, :https, :data
|
|
37
|
+
policy.font_src :self, :https, :data
|
|
38
|
+
policy.object_src :none
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
config.content_security_policy_nonce_generator = ->(req) { req.session.id.to_s }
|
|
42
|
+
config.content_security_policy_nonce_directives = %w[script-src style-src]
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then audit Stimulus controllers and Bootstrap usage for inline scripts/styles that need nonces.
|
|
47
|
+
|
|
48
|
+
## CSRF
|
|
49
|
+
|
|
50
|
+
Rails default — `protect_from_forgery with: :exception` is implicit in `ActionController::Base`. Devise + Turbo handle the token automatically. **Don't** disable CSRF on a controller unless it's a webhook endpoint, in which case use a different authentication mechanism (signature verification).
|
|
51
|
+
|
|
52
|
+
## XSS
|
|
53
|
+
|
|
54
|
+
- Slim auto-escapes by default. `==` and `raw` and `html_safe` bypass it — never use them on user input.
|
|
55
|
+
- ViewComponents render via Slim, so the same rule applies.
|
|
56
|
+
- `escape_javascript` is used in `OperationsMethods#endpoint`'s `format.js` branch when injecting modal HTML — keep that helper in any new JS-response code that interpolates a string.
|
|
57
|
+
|
|
58
|
+
## SQL injection
|
|
59
|
+
|
|
60
|
+
- ActiveRecord query methods (`where`, `joins`) auto-escape when given hashes / arrays. Never interpolate user input into raw SQL: prefer `where("name LIKE ?", "%#{q}%")` (parameterized) over `where("name LIKE '%#{q}%'")`.
|
|
61
|
+
- For `order(...)`, user-controlled column names are dangerous — use the `Base::Operation::Sortable#apply_sorting` helper, which validates against an `allowed_columns` allowlist.
|
|
62
|
+
|
|
63
|
+
## Browser support
|
|
64
|
+
|
|
65
|
+
`ApplicationController` calls `allow_browser versions: :modern`. Old browsers receive a 426 page. This is intentional: it lets us assume modern JS / CSS and simplifies CSP and Hotwire support.
|
|
66
|
+
|
|
67
|
+
## Devise modules currently enabled
|
|
68
|
+
|
|
69
|
+
`:database_authenticatable, :registerable, :recoverable, :rememberable, :validatable`.
|
|
70
|
+
|
|
71
|
+
NOT enabled (and probably should be, eventually):
|
|
72
|
+
|
|
73
|
+
- `:lockable` — lock account after N failed attempts (sensible to add).
|
|
74
|
+
- `:trackable` — record sign-in count / IP / timestamps.
|
|
75
|
+
- `:confirmable` — email confirmation.
|
|
76
|
+
- `:timeoutable` — auto-logout after inactivity.
|
|
77
|
+
|
|
78
|
+
Adding any of these requires a migration (see Devise docs) plus enabling them in `User`.
|
|
79
|
+
|
|
80
|
+
## Brakeman
|
|
81
|
+
|
|
82
|
+
`bin/brakeman --no-pager` runs in CI. If it flags something:
|
|
83
|
+
|
|
84
|
+
1. Fix the underlying issue if possible.
|
|
85
|
+
2. If it's a false positive, document why with a comment and add an inline ignore (`# brakeman:ignore`) — but prefer fixes over ignores.
|
|
86
|
+
|
|
87
|
+
## importmap audit
|
|
88
|
+
|
|
89
|
+
`bin/importmap audit` runs in CI and flags JS dependencies with known CVEs. Pinned via `config/importmap.rb`.
|
|
90
|
+
|
|
91
|
+
## Mass assignment
|
|
92
|
+
|
|
93
|
+
Use **strong parameters** in operations:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
def perform!(params:, current_user:)
|
|
97
|
+
attrs = params.require(:property).permit(:name, :description)
|
|
98
|
+
self.model = Crm::Property.new(attrs)
|
|
99
|
+
authorize_and_save!
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Never `params.permit!` or `params.to_unsafe_h` unless you have a *very* specific reason and a comment explaining it.
|
|
104
|
+
|
|
105
|
+
## Webhook / unauthenticated endpoints
|
|
106
|
+
|
|
107
|
+
There are none in the project right now. When the first one lands:
|
|
108
|
+
|
|
109
|
+
- Skip Devise: `skip_before_action :authenticate_user!`
|
|
110
|
+
- Skip CSRF: `protect_from_forgery with: :null_session, only: [:webhook]`
|
|
111
|
+
- Verify the request signature (HMAC, JWT, etc.) inside the operation.
|
|
112
|
+
- `skip_authorize` only after signature verification passes — and document why.
|
|
113
|
+
|
|
114
|
+
## Anti-patterns
|
|
115
|
+
|
|
116
|
+
- ❌ Skipping authorization in operations (`skip_authorize` without a comment explaining why).
|
|
117
|
+
- ❌ Building SQL with string interpolation.
|
|
118
|
+
- ❌ `params.permit!` / `params.to_unsafe_h`.
|
|
119
|
+
- ❌ `==` / `raw` / `html_safe` in Slim or ViewComponent templates on anything user-controlled.
|
|
120
|
+
- ❌ `before_action :skip_authorization` on an entire controller.
|
|
121
|
+
- ❌ Logging request bodies that contain unfiltered sensitive data.
|
|
122
|
+
- ❌ Using `find_by_sql` with interpolated input.
|
data/lib/generators/tsykvas_rails_template/install/templates/.claude/docs/stimulus-controllers.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Stimulus Controllers
|
|
2
|
+
|
|
3
|
+
All JavaScript interactivity uses **Hotwire Stimulus**. Controllers live in `app/javascript/controllers/`.
|
|
4
|
+
|
|
5
|
+
## File naming & registration
|
|
6
|
+
|
|
7
|
+
Controllers are auto-loaded via `eagerLoadControllersFrom` (`app/javascript/controllers/index.js`) — **no manual registration needed**.
|
|
8
|
+
|
|
9
|
+
- `controllers/dropdown_controller.js` → identifier `dropdown`
|
|
10
|
+
- `controllers/navbar_controller.js` → identifier `navbar`
|
|
11
|
+
- `controllers/some_feature/loader_controller.js` → identifier `some-feature--loader`
|
|
12
|
+
|
|
13
|
+
Rules:
|
|
14
|
+
|
|
15
|
+
- Filename: `snake_case_controller.js`
|
|
16
|
+
- Underscores → hyphens in the identifier
|
|
17
|
+
- Subdirectory separator → `--` in the identifier
|
|
18
|
+
- Group related controllers in a subdirectory
|
|
19
|
+
|
|
20
|
+
## Controller structure
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
import { Controller } from "@hotwired/stimulus"
|
|
24
|
+
|
|
25
|
+
export default class extends Controller {
|
|
26
|
+
static targets = ["input", "result"]
|
|
27
|
+
static values = { delay: { type: Number, default: 300 } }
|
|
28
|
+
|
|
29
|
+
connect() {
|
|
30
|
+
// called when element is added to DOM
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
disconnect() {
|
|
34
|
+
// clean up any external event listeners here
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Always `export default class extends Controller` — never name the class.
|
|
40
|
+
|
|
41
|
+
## Targets, values, outlets
|
|
42
|
+
|
|
43
|
+
- **targets** — DOM references; auto-generates `this.inputTarget`, `this.inputTargets`, `this.hasInputTarget`
|
|
44
|
+
- **values** — reactive typed properties; use typed defaults: `{ type: String, default: 'all' }`
|
|
45
|
+
- **outlets** — cross-controller communication (rare; prefer Turbo events)
|
|
46
|
+
|
|
47
|
+
Use `this.has*Target` guard before accessing optional targets:
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
if (this.hasLoaderTarget) {
|
|
51
|
+
this.loaderTarget.style.display = 'none'
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Event listeners on `document` / outside the element
|
|
56
|
+
|
|
57
|
+
Use **arrow-function class fields** so `this` is preserved, and **always remove them in `disconnect()`**:
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
connect() {
|
|
61
|
+
document.addEventListener("turbo:submit-start", this.handleSubmitStart)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
disconnect() {
|
|
65
|
+
document.removeEventListener("turbo:submit-start", this.handleSubmitStart)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
handleSubmitStart = (event) => {
|
|
69
|
+
if (this.element.contains(event.target)) { ... }
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Never add document-level listeners with `.bind(this)` in `connect()` — the bound function is a new reference each call, so you can't `removeEventListener` it. (Existing controllers like `dropdown_controller.js` use `this.boundXxx = handler.bind(this)` and store the reference; that works too — but arrow-function class fields are simpler.)
|
|
74
|
+
|
|
75
|
+
Always scope handlers to `this.element.contains(event.target)` to avoid reacting to other frames/forms.
|
|
76
|
+
|
|
77
|
+
## Turbo integration
|
|
78
|
+
|
|
79
|
+
Useful Turbo lifecycle events:
|
|
80
|
+
|
|
81
|
+
- `turbo:load` / `turbo:render` — page navigated
|
|
82
|
+
- `turbo:submit-start` / `turbo:submit-end` — form submission
|
|
83
|
+
- `turbo:before-fetch-request` — any Turbo fetch starts
|
|
84
|
+
- `turbo:frame-load` — Turbo Frame finished loading
|
|
85
|
+
|
|
86
|
+
## Bootstrap integration
|
|
87
|
+
|
|
88
|
+
Bootstrap is loaded globally and exposed as `window.bootstrap`:
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
new window.bootstrap.Modal(this.element).show()
|
|
92
|
+
new window.bootstrap.Tooltip(this.element)
|
|
93
|
+
window.bootstrap.Dropdown.getInstance(this.element)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
No import needed.
|
|
97
|
+
|
|
98
|
+
## Passing data from Rails to Stimulus
|
|
99
|
+
|
|
100
|
+
Use `data-*-value` attributes for scalar values and `data-*-param` on action elements:
|
|
101
|
+
|
|
102
|
+
```slim
|
|
103
|
+
div data-controller="users-search"
|
|
104
|
+
input data-action="input->users-search#search" data-users-search-target="input"
|
|
105
|
+
button data-action="click->users-search#clear" data-users-search-default-param="all"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
search() { this.fetchResults(this.inputTarget.value) }
|
|
110
|
+
|
|
111
|
+
clear({ params: { default: defaultValue } }) {
|
|
112
|
+
this.inputTarget.value = defaultValue || ''
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Debouncing
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
connect() { this.timeout = null }
|
|
120
|
+
|
|
121
|
+
search() {
|
|
122
|
+
clearTimeout(this.timeout)
|
|
123
|
+
this.timeout = setTimeout(() => this.performSearch(), this.delayValue)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
disconnect() { clearTimeout(this.timeout) }
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## What belongs in a Stimulus controller vs. elsewhere
|
|
130
|
+
|
|
131
|
+
- UI interactivity (show/hide, toggle, debounce, modals) — Stimulus
|
|
132
|
+
- Fetching data / form submissions — Turbo (frames, streams); controllers only trigger or react
|
|
133
|
+
- Application-wide state / complex logic — keep minimal; split into focused controllers
|
|
134
|
+
- Third-party widget init (Bootstrap, etc.) — `connect()` / `disconnect()` lifecycle
|
|
135
|
+
|
|
136
|
+
Keep controllers small and single-purpose.
|
|
137
|
+
|
|
138
|
+
## Existing controllers (reference)
|
|
139
|
+
|
|
140
|
+
| File | Identifier | Purpose |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `application.js` | — | Stimulus app bootstrap |
|
|
143
|
+
| `index.js` | — | `eagerLoadControllersFrom` registration |
|
|
144
|
+
| `dropdown_controller.js` | `dropdown` | Bootstrap dropdown lifecycle + click-outside-to-close. Uses `boundXxx = handler.bind(this)` pattern (legacy) — new controllers should prefer arrow-function class fields. |
|
|
145
|
+
| `navbar_controller.js` | `navbar` | Top navbar interactions (mobile collapse, active link highlighting). |
|
|
146
|
+
| `theme_controller.js` | `theme` | Light/dark theme toggle. Persists choice in `localStorage`, sets `data-theme` and `data-bs-theme` on `<html>` and `<body>` (Bootstrap reads `data-bs-theme`). |
|
|
147
|
+
| `hello_controller.js` | `hello` | Stimulus boilerplate — safe to delete once replaced by real controllers. |
|
|
148
|
+
|
|
149
|
+
### Theme controller pattern
|
|
150
|
+
|
|
151
|
+
`theme_controller.js` is the reference for "persisted UI state" controllers:
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
connect() {
|
|
155
|
+
const savedTheme = localStorage.getItem('theme') || 'light'
|
|
156
|
+
this.setTheme(savedTheme)
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Notes:
|
|
161
|
+
- Reads `localStorage` in `connect()` so theme survives Turbo navigation.
|
|
162
|
+
- Mirrors state to BOTH `data-theme` (for app CSS) and `data-bs-theme` (for Bootstrap 5).
|
|
163
|
+
- Optional `toggle` target — guarded with `this.hasToggleTarget`.
|
|
164
|
+
- The toggle button can be either an `<i>` icon or a wrapper containing `<i>` — the controller handles both.
|
|
165
|
+
|
|
166
|
+
When you add similar persisted UI controllers (sidebar collapse, font-size, density), follow the same shape: read in `connect`, write in `setX`, no listeners on `document`.
|