cafe_car 0.3.0 → 0.3.1
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 +4 -4
- data/README.md +13 -4
- data/app/controllers/cafe_car/dashboards_controller.rb +12 -4
- data/app/views/application/_debug.html.haml +0 -7
- data/app/views/application/_has_many_filter.html.haml +1 -1
- data/lib/cafe_car/controller/association_authorization.rb +114 -0
- data/lib/cafe_car/controller.rb +11 -8
- data/lib/cafe_car/field_info.rb +1 -1
- data/lib/cafe_car/form_builder.rb +34 -4
- data/lib/cafe_car/helpers.rb +8 -5
- data/lib/cafe_car/version.rb +1 -1
- data/skills/cafe_car/references/navigation.md +14 -3
- data/skills/cafe_car/references/policies.md +6 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f60b0fb2f1007109068b603e6b74d167017ae1e025752c86369f0c680a30fdfc
|
|
4
|
+
data.tar.gz: 9e9d9005160ef6cd09a3c2eda12083d2286d3d4353be85cb904e073d8e21ca89
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: baac158f6e2a02c520faea65399352b56d6e54e238092fcee028411ffb4a36fdcae0ac76429dd4e090ecbfb109795dd47b247e5731a51b972e9bd25c87b389ff
|
|
7
|
+
data.tar.gz: 9f08a42247edd2bc6302e6372b40a1ecefaadb2ddcddedb385b84b918456aca5bf6e4ec922a1750e91e9d455180f178d0dd8b95445ff1db17d8c80226df8543f
|
data/README.md
CHANGED
|
@@ -913,6 +913,14 @@ you write one view.** Its existence turns the dashboard on; delete it and there'
|
|
|
913
913
|
no dashboard at all (a direct hit 404s, no nav link), so a CRUD-only app never
|
|
914
914
|
inherits a blank page.
|
|
915
915
|
|
|
916
|
+
Authorize the page with an ordinary Pundit policy:
|
|
917
|
+
|
|
918
|
+
```ruby
|
|
919
|
+
class DashboardPolicy < ApplicationPolicy
|
|
920
|
+
def show? = user.present?
|
|
921
|
+
end
|
|
922
|
+
```
|
|
923
|
+
|
|
916
924
|
Write `app/views/cafe_car/dashboard/show.html.haml`:
|
|
917
925
|
|
|
918
926
|
```haml
|
|
@@ -932,13 +940,14 @@ Three helpers compose the page:
|
|
|
932
940
|
returns.
|
|
933
941
|
- **`metrics(Model)`** — the tiles a **policy** declares. `Model`'s policy lists
|
|
934
942
|
the scopes to surface in `permitted_metrics` (`:all` = the whole relation), and
|
|
935
|
-
CafeCar renders a count tile for each — the same
|
|
936
|
-
as bulk actions.
|
|
943
|
+
CafeCar renders a count tile for each over `policy_scope(Model)` — the same
|
|
944
|
+
policy-is-source-of-truth rule as bulk actions.
|
|
937
945
|
- **`chart "Title", model:, x:, by:`** — the same dependency-free inline-SVG bar
|
|
938
946
|
chart as the index [Chart view](#advanced-usage), bucketing `model`'s records
|
|
939
947
|
over the `x` date column at `by` granularity (`:day`/`:week`/`:month`, default
|
|
940
|
-
`:month`).
|
|
941
|
-
and truncated with portable Arel, so
|
|
948
|
+
`:month`). It reads from `policy_scope(model)`; the `x` column is validated
|
|
949
|
+
against the model's date-column allowlist and truncated with portable Arel, so
|
|
950
|
+
it's never interpolated as raw SQL.
|
|
942
951
|
|
|
943
952
|
```ruby
|
|
944
953
|
class UserPolicy < ApplicationPolicy
|
|
@@ -3,17 +3,25 @@ module CafeCar
|
|
|
3
3
|
# template — `app/views/cafe_car/dashboard/show.html.haml` — that composes the
|
|
4
4
|
# `metric`/`chart` helpers (and the policy-driven `metrics` helper). Its existence
|
|
5
5
|
# IS the opt-in: the route always mounts, but with no host template this 404s, so
|
|
6
|
-
# a CRUD-only host never inherits a blank page.
|
|
7
|
-
#
|
|
6
|
+
# a CRUD-only host never inherits a blank page. An opted-in dashboard authorizes
|
|
7
|
+
# the conventional `DashboardPolicy#show?`; its model helpers apply each model's
|
|
8
|
+
# policy scope independently.
|
|
8
9
|
class DashboardsController < const(:ApplicationController)
|
|
9
10
|
include Controller
|
|
10
11
|
helper CafeCar::Helpers
|
|
11
12
|
|
|
13
|
+
rescue_from ::Pundit::NotAuthorizedError, ::Pundit::NotDefinedError, with: :render_unauthorized
|
|
14
|
+
|
|
12
15
|
before_action :skip_policy_scope
|
|
13
|
-
|
|
16
|
+
after_action :verify_authorized
|
|
14
17
|
|
|
15
18
|
def show
|
|
16
|
-
|
|
19
|
+
unless dashboard_template?
|
|
20
|
+
skip_authorization
|
|
21
|
+
return head(:not_found)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
authorize :dashboard, :show?
|
|
17
25
|
render "cafe_car/dashboard/show"
|
|
18
26
|
end
|
|
19
27
|
|
|
@@ -8,11 +8,4 @@
|
|
|
8
8
|
= Field label: "Query" do
|
|
9
9
|
= p scope.to_sql, as: :code, lang: :sql
|
|
10
10
|
|
|
11
|
-
- if session = current_session
|
|
12
|
-
= Field label: "Session" do
|
|
13
|
-
= p session.as_json
|
|
14
|
-
|
|
15
|
-
= Field label: "Cookies" do
|
|
16
|
-
= p cookies.signed.as_json.to_s, as: :code
|
|
17
|
-
|
|
18
11
|
= yield :debug
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
-# query DSL turns into a where_assoc_exists matching any of the chosen ids.
|
|
3
3
|
= Field do
|
|
4
4
|
= f.label info.method
|
|
5
|
-
= f.collection_select info.method, info
|
|
5
|
+
= f.collection_select info.method, f.association_collection(info), :id, -> { present(_1).title },
|
|
6
6
|
{ selected: f.object.value(info.method, :id) },
|
|
7
7
|
{ multiple: true, name: "#{f.field_name(info.method, :id)}[]", **f.searchable_select(info) }
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
module CafeCar::Controller::AssociationAuthorization
|
|
2
|
+
private
|
|
3
|
+
|
|
4
|
+
# A parent policy controls whether an association key is editable; the
|
|
5
|
+
# associated model's policy scope controls WHICH records may be assigned.
|
|
6
|
+
# Enforce both halves server-side so a crafted foreign key cannot select a
|
|
7
|
+
# row the association typeahead and initial options correctly hide.
|
|
8
|
+
def authorize_association_attributes!(record, policy, attributes)
|
|
9
|
+
checked = {}
|
|
10
|
+
|
|
11
|
+
attributes.each_key do |key|
|
|
12
|
+
info = CafeCar[:FieldInfo].new(model: record.class, method: key)
|
|
13
|
+
reflection = info.reflection
|
|
14
|
+
next unless reflection
|
|
15
|
+
next if info.attachment?
|
|
16
|
+
|
|
17
|
+
if info.type == :nested
|
|
18
|
+
authorize_nested_associations!(record, reflection, attributes[key])
|
|
19
|
+
elsif reflection.polymorphic?
|
|
20
|
+
next if checked[reflection.name]
|
|
21
|
+
checked[reflection.name] = true
|
|
22
|
+
authorize_polymorphic_association!(record, policy, reflection, attributes)
|
|
23
|
+
elsif reflection.macro.in?(%i[belongs_to has_many])
|
|
24
|
+
authorize_association_ids!(record, policy, reflection, attributes[key])
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def authorize_nested_associations!(record, reflection, value)
|
|
30
|
+
nested_attributes(reflection, value).each do |attributes|
|
|
31
|
+
next if destroy_nested_record?(attributes)
|
|
32
|
+
|
|
33
|
+
nested = nested_record(record, reflection, attributes)
|
|
34
|
+
authorize_association_attributes!(nested, policy(nested), attributes)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def authorize_polymorphic_association!(record, policy, reflection, attributes)
|
|
39
|
+
id = attributes[reflection.foreign_key]
|
|
40
|
+
type = attributes[reflection.foreign_type]
|
|
41
|
+
return if id.blank? && type.blank?
|
|
42
|
+
|
|
43
|
+
deny_association!(record, policy, reflection) if id.blank? || type.blank?
|
|
44
|
+
|
|
45
|
+
klass = record.class.polymorphic_class_for(type)
|
|
46
|
+
deny_association!(record, policy, reflection) unless klass <= ActiveRecord::Base
|
|
47
|
+
authorize_association_ids!(record, policy, reflection, id, klass:)
|
|
48
|
+
rescue NameError, ArgumentError, Pundit::NotDefinedError
|
|
49
|
+
deny_association!(record, policy, reflection)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def authorize_association_ids!(record, policy, reflection, value, klass: reflection.klass)
|
|
53
|
+
ids = Array.wrap(value).compact_blank.map(&:to_s).uniq
|
|
54
|
+
ids -= existing_association_ids(record, reflection, klass, ids)
|
|
55
|
+
return if ids.empty?
|
|
56
|
+
|
|
57
|
+
authorize klass, :index?
|
|
58
|
+
scope = policy_scope(klass)
|
|
59
|
+
primary = klass.primary_key
|
|
60
|
+
allowed = scope.where(primary => ids).pluck(primary).map(&:to_s)
|
|
61
|
+
deny_association!(record, policy, reflection) unless (ids - allowed).empty?
|
|
62
|
+
rescue Pundit::NotDefinedError
|
|
63
|
+
deny_association!(record, policy, reflection)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def existing_association_ids(record, reflection, klass, ids)
|
|
67
|
+
return [] unless record.persisted?
|
|
68
|
+
|
|
69
|
+
if reflection.polymorphic?
|
|
70
|
+
type = record.public_send(reflection.foreign_type)
|
|
71
|
+
return [] unless type && record.class.polymorphic_class_for(type) == klass
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if reflection.belongs_to?
|
|
75
|
+
[ record.public_send(reflection.foreign_key).to_s ]
|
|
76
|
+
else
|
|
77
|
+
primary = klass.primary_key
|
|
78
|
+
record.association(reflection.name).scope.where(primary => ids).pluck(primary).map(&:to_s)
|
|
79
|
+
end
|
|
80
|
+
rescue NameError, ArgumentError
|
|
81
|
+
[]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def nested_attributes(reflection, value)
|
|
85
|
+
values = value.respond_to?(:to_unsafe_h) ? value.to_unsafe_h : value
|
|
86
|
+
return Array.wrap(values).compact unless reflection.collection?
|
|
87
|
+
|
|
88
|
+
values.is_a?(Hash) ? values.values.compact : Array.wrap(values).compact
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def nested_record(record, reflection, attributes)
|
|
92
|
+
attributes = attributes.with_indifferent_access
|
|
93
|
+
id = attributes[reflection.klass.primary_key]
|
|
94
|
+
return reflection.klass.new unless id
|
|
95
|
+
|
|
96
|
+
association = record.association(reflection.name)
|
|
97
|
+
if reflection.collection?
|
|
98
|
+
association.scope.find_by(reflection.klass.primary_key => id)
|
|
99
|
+
else
|
|
100
|
+
association.target if association.target&.id.to_s == id.to_s
|
|
101
|
+
end || reflection.klass.new
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def destroy_nested_record?(attributes)
|
|
105
|
+
value = attributes.respond_to?(:[]) && (attributes[:_destroy] || attributes["_destroy"])
|
|
106
|
+
ActiveModel::Type::Boolean.new.cast(value)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def deny_association!(record, policy, reflection)
|
|
110
|
+
raise Pundit::NotAuthorizedError.new(
|
|
111
|
+
query: "associate_#{reflection.name}?", record:, policy:
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/cafe_car/controller.rb
CHANGED
|
@@ -4,8 +4,10 @@ module CafeCar
|
|
|
4
4
|
module Controller
|
|
5
5
|
extend ActiveSupport::Concern
|
|
6
6
|
|
|
7
|
+
INDEX_VIEWS = %w[table grid chart].freeze
|
|
8
|
+
|
|
7
9
|
include Pundit::Authorization
|
|
8
|
-
include Filtering, Authentication
|
|
10
|
+
include Filtering, AssociationAuthorization, Authentication
|
|
9
11
|
|
|
10
12
|
class_methods do
|
|
11
13
|
def model(model)
|
|
@@ -311,7 +313,9 @@ module CafeCar
|
|
|
311
313
|
method = "permitted_attributes" unless policy.respond_to?(method)
|
|
312
314
|
multi = multiple_attachments(record)
|
|
313
315
|
keys = policy.public_send(method).map { |k| multi.include?(k) ? { k => [] } : k }
|
|
314
|
-
pundit_params_for(record).permit(*keys)
|
|
316
|
+
pundit_params_for(record).permit(*keys).tap do |attributes|
|
|
317
|
+
authorize_association_attributes!(record, policy, attributes)
|
|
318
|
+
end
|
|
315
319
|
end
|
|
316
320
|
|
|
317
321
|
# Names of the record's `has_many_attached` attachments — the fields whose
|
|
@@ -399,7 +403,9 @@ module CafeCar
|
|
|
399
403
|
|
|
400
404
|
def default_view = self.class.default_view
|
|
401
405
|
def view
|
|
402
|
-
params
|
|
406
|
+
[ params[:view], default_view, "table" ].compact
|
|
407
|
+
.map(&:to_s)
|
|
408
|
+
.find { _1.in?(INDEX_VIEWS) }
|
|
403
409
|
end
|
|
404
410
|
|
|
405
411
|
def _render_with_renderer_json(obj, options)
|
|
@@ -410,11 +416,8 @@ module CafeCar
|
|
|
410
416
|
# permitted_attributes is record-oriented, so ask a record for the column
|
|
411
417
|
# list even when serializing a collection.
|
|
412
418
|
record = obj.is_a?(CafeCar::Model) ? obj : obj.klass.new
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
if obj.is_a?(CafeCar::Model)
|
|
416
|
-
options[:include] ||= policy(obj).displayable_associations
|
|
417
|
-
end
|
|
419
|
+
displayable = [ :id ] | policy(record).attributes.displayable
|
|
420
|
+
options[:only] ||= displayable & record.attribute_names.map(&:to_sym)
|
|
418
421
|
|
|
419
422
|
super obj, **options
|
|
420
423
|
end
|
data/lib/cafe_car/field_info.rb
CHANGED
|
@@ -31,7 +31,7 @@ module CafeCar
|
|
|
31
31
|
def rich_text? = reflection&.name =~ /^rich_text_(\w+)$/
|
|
32
32
|
def attachment? = model.reflect_on_attachment(method)
|
|
33
33
|
def multiple? = attachment?&.macro == :has_many_attached
|
|
34
|
-
def collection
|
|
34
|
+
def collection(scope = reflection.klass.all) = scope.limit(CafeCar.max_collection_options)
|
|
35
35
|
def reflection
|
|
36
36
|
return if @method.nil?
|
|
37
37
|
# A nested-attributes permit names the key `<assoc>_attributes` (what Rails'
|
|
@@ -18,8 +18,9 @@ module CafeCar
|
|
|
18
18
|
|
|
19
19
|
return show(info.input_key) if info.polymorphic? and object.persisted?
|
|
20
20
|
return hidden(*info.polymorphic_methods) if info.polymorphic?
|
|
21
|
+
return preserved_association(info) unless association_accessible?(info)
|
|
21
22
|
|
|
22
|
-
collection ||= with_selected(info)
|
|
23
|
+
collection ||= with_selected(info, association_collection(info))
|
|
23
24
|
# A multi-select filters by a set (`author_id[]`), so no blank "any" option —
|
|
24
25
|
# an empty selection already means "any". A single select keeps its prompt.
|
|
25
26
|
options[:include_blank] ||= info.prompt unless multiple
|
|
@@ -31,6 +32,20 @@ module CafeCar
|
|
|
31
32
|
-> { @template.present(_1).title }, options, html)
|
|
32
33
|
end
|
|
33
34
|
|
|
35
|
+
# Association choices obey the associated model's Pundit scope. The remote
|
|
36
|
+
# typeahead endpoint already applies this boundary; using the same boundary
|
|
37
|
+
# for the initial HTML options keeps the progressive-enhancement path from
|
|
38
|
+
# leaking rows the user cannot reach through that endpoint.
|
|
39
|
+
def association_collection(info)
|
|
40
|
+
klass = info.reflection.klass
|
|
41
|
+
scope = association_accessible?(info) ? @template.policy_scope(klass) : klass.none
|
|
42
|
+
info.collection(scope)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def association_accessible?(info)
|
|
46
|
+
@template.policy(info.reflection.klass).index?
|
|
47
|
+
end
|
|
48
|
+
|
|
34
49
|
# HTML options that flag an association <select> for Tom Select enhancement
|
|
35
50
|
# (see cafe_car.js). When the associated model exposes an `options` typeahead
|
|
36
51
|
# feed, its URL rides along so keystroke search can reach records past
|
|
@@ -53,15 +68,30 @@ module CafeCar
|
|
|
53
68
|
# The capped option collection, guaranteeing the currently-associated record is
|
|
54
69
|
# among the options even when it sorts past the cap — otherwise editing a record
|
|
55
70
|
# whose association is beyond `max_collection_options` would silently drop the value.
|
|
56
|
-
def with_selected(info)
|
|
57
|
-
collection = info.collection
|
|
71
|
+
def with_selected(info, collection)
|
|
58
72
|
return collection unless info.reflection&.belongs_to?
|
|
73
|
+
return collection unless association_accessible?(info)
|
|
59
74
|
|
|
60
75
|
selected = object.try(info.reflection.name)
|
|
61
76
|
return collection unless selected
|
|
62
77
|
|
|
63
78
|
records = collection.to_a
|
|
64
|
-
records.include?(selected)
|
|
79
|
+
return records if records.include?(selected)
|
|
80
|
+
|
|
81
|
+
scope = @template.policy_scope(info.reflection.klass)
|
|
82
|
+
scope.where(id: selected.id).exists? ? [ selected, *records ] : records
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A persisted association can sit outside the viewer's current list boundary
|
|
86
|
+
# (or listing can be denied wholesale). Keep that foreign key through an
|
|
87
|
+
# unrelated edit without exposing the associated record as a labelled option.
|
|
88
|
+
# The controller accepts only this unchanged value; any reassignment still
|
|
89
|
+
# requires `index?` and membership in the associated policy scope.
|
|
90
|
+
def preserved_association(info)
|
|
91
|
+
return @template.safe_join([]) unless object.respond_to?(:persisted?) && object.persisted?
|
|
92
|
+
return @template.safe_join([]) unless info.reflection.belongs_to?
|
|
93
|
+
|
|
94
|
+
hidden(info.input_key)
|
|
65
95
|
end
|
|
66
96
|
|
|
67
97
|
# An ActiveRecord enum renders as a plain <select> of its declared values,
|
data/lib/cafe_car/helpers.rb
CHANGED
|
@@ -146,8 +146,9 @@ module CafeCar
|
|
|
146
146
|
ids = Array.wrap(value)
|
|
147
147
|
assoc = filter_association(klass, key)
|
|
148
148
|
return ids.join(", ") unless assoc
|
|
149
|
+
return ids.join(", ") unless policy(assoc).index?
|
|
149
150
|
|
|
150
|
-
titles = assoc.where(id: ids).index_by { _1.id.to_s }
|
|
151
|
+
titles = policy_scope(assoc).where(id: ids).index_by { _1.id.to_s }
|
|
151
152
|
ids.map { |id| titles[id.to_s]&.then { present(_1).title } || id }.join(", ")
|
|
152
153
|
end
|
|
153
154
|
|
|
@@ -299,9 +300,10 @@ module CafeCar
|
|
|
299
300
|
# A dashboard chart tile: a title over the dependency-free inline-SVG bar chart,
|
|
300
301
|
# built from `model`'s records bucketed over the `x` date column at `by`
|
|
301
302
|
# granularity. `x` runs through ChartBuilder's date-column allowlist unchanged,
|
|
302
|
-
# so a column name can never reach SQL raw.
|
|
303
|
+
# so a column name can never reach SQL raw. The model's policy scope is the
|
|
304
|
+
# chart's row boundary.
|
|
303
305
|
def chart(title, model:, x:, by: nil)
|
|
304
|
-
render "cafe_car/dashboard/chart", title:, objects: model
|
|
306
|
+
render "cafe_car/dashboard/chart", title:, objects: policy_scope(model), x:, by:
|
|
305
307
|
end
|
|
306
308
|
|
|
307
309
|
# The policy-driven metric tiles for `model`: one count tile per name in the
|
|
@@ -314,7 +316,8 @@ module CafeCar
|
|
|
314
316
|
end
|
|
315
317
|
|
|
316
318
|
def metric_for(model, name)
|
|
317
|
-
scope =
|
|
319
|
+
scope = policy_scope(model)
|
|
320
|
+
scope = scope.public_send(name) unless name.to_sym == :all
|
|
318
321
|
metric(metric_label(model, name)) { scope.count }
|
|
319
322
|
end
|
|
320
323
|
|
|
@@ -339,7 +342,7 @@ module CafeCar
|
|
|
339
342
|
end
|
|
340
343
|
end
|
|
341
344
|
|
|
342
|
-
def debug? = params.key?(:debug)
|
|
345
|
+
def debug? = Rails.env.development? && request.local? && params.key?(:debug)
|
|
343
346
|
def console? = params.key?(:console)
|
|
344
347
|
|
|
345
348
|
def comment(text)
|
data/lib/cafe_car/version.rb
CHANGED
|
@@ -43,7 +43,16 @@ Override the partials like any other ([views.md](views.md)):
|
|
|
43
43
|
## The dashboard — opt in by writing one view
|
|
44
44
|
|
|
45
45
|
No template, no dashboard: the route always exists, but it 404s and shows no nav
|
|
46
|
-
link until the host writes `app/views/cafe_car/dashboard/show.html.haml
|
|
46
|
+
link until the host writes `app/views/cafe_car/dashboard/show.html.haml`. The page
|
|
47
|
+
uses ordinary Pundit authorization:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
class DashboardPolicy < ApplicationPolicy
|
|
51
|
+
def show? = user.present?
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Then write the view:
|
|
47
56
|
|
|
48
57
|
```haml
|
|
49
58
|
- title "Dashboard"
|
|
@@ -57,11 +66,13 @@ link until the host writes `app/views/cafe_car/dashboard/show.html.haml`:
|
|
|
57
66
|
```
|
|
58
67
|
|
|
59
68
|
- `metrics(Model)` — one count tile per scope named in the model policy's
|
|
60
|
-
`permitted_metrics` (`:all` = whole
|
|
69
|
+
`permitted_metrics` (`:all` = the whole policy scope). Policy-driven; the default
|
|
70
|
+
choice.
|
|
61
71
|
- `metric("Label") { … }` — one tile with whatever the block returns.
|
|
62
72
|
- `chart "Title", model:, x:, by:` — the inline-SVG bar chart, bucketing over the
|
|
63
73
|
`x` date column at `:day`/`:week`/`:month`. Column names are validated against
|
|
64
|
-
the policy's date columns — never raw
|
|
74
|
+
the policy's date columns, and rows come from `policy_scope(model)` — never raw
|
|
75
|
+
SQL or unscoped data.
|
|
65
76
|
|
|
66
77
|
It's a plain view: add headings, your own partials, anything between tiles. Once
|
|
67
78
|
the file exists a Dashboard link appears at the top of the sidebar.
|
|
@@ -59,6 +59,12 @@ end
|
|
|
59
59
|
def permitted_attributes_for_create = %i[name email owner_id] # per-action variant
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
For associations, the parent policy decides whether the foreign key is editable,
|
|
63
|
+
and the associated model's `index?` permission plus policy scope decide which
|
|
64
|
+
records may be selected. CafeCar applies that boundary to the initial select, the
|
|
65
|
+
remote typeahead, and submitted ids (including nested and polymorphic ids); a
|
|
66
|
+
crafted id outside it is denied server-side.
|
|
67
|
+
|
|
62
68
|
Nested records permit `<assoc>_attributes` the Rails way (with `:id` + `:_destroy`
|
|
63
69
|
for `allow_destroy`):
|
|
64
70
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cafe_car
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jeff Peterson
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -388,6 +388,7 @@ files:
|
|
|
388
388
|
- lib/cafe_car/component.rb
|
|
389
389
|
- lib/cafe_car/context.rb
|
|
390
390
|
- lib/cafe_car/controller.rb
|
|
391
|
+
- lib/cafe_car/controller/association_authorization.rb
|
|
391
392
|
- lib/cafe_car/controller/filtering.rb
|
|
392
393
|
- lib/cafe_car/core_ext.rb
|
|
393
394
|
- lib/cafe_car/core_ext/array.rb
|