clickwrap 0.0.0 → 0.1.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/.rubocop.yml +90 -0
- data/CHANGELOG.md +650 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +245 -0
- data/app/controllers/clickwrap/application_controller.rb +79 -0
- data/app/controllers/clickwrap/captures_controller.rb +145 -0
- data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
- data/app/controllers/clickwrap/receipts_controller.rb +115 -0
- data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
- data/app/helpers/clickwrap/engine_helper.rb +97 -0
- data/app/views/clickwrap/captures/show.html.erb +34 -0
- data/app/views/clickwrap/receipts/index.html.erb +38 -0
- data/app/views/clickwrap/receipts/show.html.erb +91 -0
- data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
- data/app/views/clickwrap/shared/_fields.html.erb +100 -0
- data/app/views/clickwrap/shared/_statement.html.erb +105 -0
- data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
- data/config/locales/en.yml +160 -0
- data/config/locales/es.yml +154 -0
- data/config/routes.rb +41 -0
- data/exe/clickwrap +374 -0
- data/guides/README.md +30 -0
- data/guides/accessibility.md +249 -0
- data/guides/consent-and-lifecycle.md +299 -0
- data/guides/integrating.md +610 -0
- data/guides/integrity.md +212 -0
- data/guides/migrating.md +335 -0
- data/guides/naming.md +320 -0
- data/guides/organizations.md +320 -0
- data/guides/receipts-and-verification.md +415 -0
- data/guides/request-evidence.md +512 -0
- data/guides/retention-and-legal-holds.md +438 -0
- data/lib/clickwrap/actor_proxy.rb +147 -0
- data/lib/clickwrap/anonymous_actor.rb +47 -0
- data/lib/clickwrap/authority.rb +174 -0
- data/lib/clickwrap/canonical_json.rb +216 -0
- data/lib/clickwrap/capture/event_builder.rb +220 -0
- data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
- data/lib/clickwrap/capture.rb +650 -0
- data/lib/clickwrap/configuration.rb +1129 -0
- data/lib/clickwrap/controller_helpers.rb +758 -0
- data/lib/clickwrap/current_state.rb +282 -0
- data/lib/clickwrap/digest.rb +125 -0
- data/lib/clickwrap/doctor.rb +418 -0
- data/lib/clickwrap/document_definition.rb +255 -0
- data/lib/clickwrap/document_renderer.rb +83 -0
- data/lib/clickwrap/document_renderers/markdown.rb +175 -0
- data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
- data/lib/clickwrap/dsl/policy_builder.rb +462 -0
- data/lib/clickwrap/dsl/retention_builder.rb +89 -0
- data/lib/clickwrap/durable_commit_callback.rb +37 -0
- data/lib/clickwrap/engine.rb +187 -0
- data/lib/clickwrap/errors.rb +181 -0
- data/lib/clickwrap/form_builder_extensions.rb +341 -0
- data/lib/clickwrap/front_matter.rb +67 -0
- data/lib/clickwrap/identifier.rb +112 -0
- data/lib/clickwrap/import/external_receipt.rb +241 -0
- data/lib/clickwrap/import/fine_print.rb +290 -0
- data/lib/clickwrap/import/legacy.rb +450 -0
- data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
- data/lib/clickwrap/integrity/anchor.rb +130 -0
- data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
- data/lib/clickwrap/integrity/attestor.rb +221 -0
- data/lib/clickwrap/integrity/chain.rb +313 -0
- data/lib/clickwrap/integrity/timestamp.rb +143 -0
- data/lib/clickwrap/ip_geolocation/location.rb +112 -0
- data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
- data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
- data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
- data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
- data/lib/clickwrap/ip_geolocation.rb +16 -0
- data/lib/clickwrap/lifecycle.rb +534 -0
- data/lib/clickwrap/linter.rb +382 -0
- data/lib/clickwrap/localized_text.rb +101 -0
- data/lib/clickwrap/macros.rb +203 -0
- data/lib/clickwrap/models/application_record.rb +20 -0
- data/lib/clickwrap/models/chain_head.rb +79 -0
- data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
- data/lib/clickwrap/models/disposition_plan.rb +208 -0
- data/lib/clickwrap/models/document.rb +46 -0
- data/lib/clickwrap/models/document_version.rb +163 -0
- data/lib/clickwrap/models/event.rb +743 -0
- data/lib/clickwrap/models/event_document.rb +79 -0
- data/lib/clickwrap/models/event_statement.rb +92 -0
- data/lib/clickwrap/models/external_action.rb +150 -0
- data/lib/clickwrap/models/integrity_attestation.rb +90 -0
- data/lib/clickwrap/models/legal_hold.rb +81 -0
- data/lib/clickwrap/models/policy_revision.rb +115 -0
- data/lib/clickwrap/models/presentation.rb +59 -0
- data/lib/clickwrap/models/receipt_access.rb +53 -0
- data/lib/clickwrap/models/recording_sequence.rb +21 -0
- data/lib/clickwrap/models/request_evidence.rb +378 -0
- data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
- data/lib/clickwrap/models/statement_state.rb +130 -0
- data/lib/clickwrap/pending_receipt.rb +177 -0
- data/lib/clickwrap/policy.rb +283 -0
- data/lib/clickwrap/presentation_manifest.rb +210 -0
- data/lib/clickwrap/presenter.rb +729 -0
- data/lib/clickwrap/privacy.rb +419 -0
- data/lib/clickwrap/protected_outcome.rb +120 -0
- data/lib/clickwrap/receipt.rb +606 -0
- data/lib/clickwrap/receipt_html.rb +235 -0
- data/lib/clickwrap/receipt_verifier.rb +978 -0
- data/lib/clickwrap/reference.rb +44 -0
- data/lib/clickwrap/registration.rb +236 -0
- data/lib/clickwrap/registry.rb +54 -0
- data/lib/clickwrap/remediation_token.rb +155 -0
- data/lib/clickwrap/request_evidence_extractor.rb +590 -0
- data/lib/clickwrap/request_evidence_policy.rb +261 -0
- data/lib/clickwrap/retention/applier.rb +231 -0
- data/lib/clickwrap/retention/disposition.rb +221 -0
- data/lib/clickwrap/retention/planner.rb +502 -0
- data/lib/clickwrap/retention_class.rb +97 -0
- data/lib/clickwrap/reviewed_text.rb +28 -0
- data/lib/clickwrap/schema_requirements.rb +196 -0
- data/lib/clickwrap/services/authorize_external_action.rb +149 -0
- data/lib/clickwrap/services/load_policies.rb +69 -0
- data/lib/clickwrap/services/publish_documents.rb +251 -0
- data/lib/clickwrap/services/validate_policy_references.rb +166 -0
- data/lib/clickwrap/statement.rb +248 -0
- data/lib/clickwrap/subject_fingerprint.rb +28 -0
- data/lib/clickwrap/submission.rb +169 -0
- data/lib/clickwrap/system_actor.rb +31 -0
- data/lib/clickwrap/test_helpers.rb +676 -0
- data/lib/clickwrap/testing.rb +211 -0
- data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
- data/lib/clickwrap/verification.rb +504 -0
- data/lib/clickwrap/version.rb +12 -1
- data/lib/clickwrap/view_helpers.rb +190 -0
- data/lib/clickwrap/vocabulary.rb +294 -0
- data/lib/clickwrap.rb +497 -7
- data/lib/generators/clickwrap/document_generator.rb +164 -0
- data/lib/generators/clickwrap/hardening_generator.rb +177 -0
- data/lib/generators/clickwrap/install_generator.rb +1287 -0
- data/lib/generators/clickwrap/link_generator.rb +56 -0
- data/lib/generators/clickwrap/policy_generator.rb +118 -0
- data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
- data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
- data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
- data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
- data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
- data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
- data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
- data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
- data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
- data/lib/generators/clickwrap/views_generator.rb +101 -0
- data/lib/tasks/clickwrap.rake +569 -0
- metadata +311 -16
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# "Show me exactly what the application recorded."
|
|
5
|
+
#
|
|
6
|
+
# Every screen here answers that question about one recorded event, and every
|
|
7
|
+
# screen here goes through the host's authorization callback to do it. There
|
|
8
|
+
# is no built-in "actors can always read their own" shortcut: the host decides
|
|
9
|
+
# who may read what, and the conventional initializer says so out loud.
|
|
10
|
+
#
|
|
11
|
+
# config.authorize_receipt_access_with = lambda do |controller, receipt|
|
|
12
|
+
# controller.current_user.present? &&
|
|
13
|
+
# (controller.current_user == receipt.actor || controller.current_user.admin?)
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Until that is configured the default answers false, so an unconfigured host
|
|
17
|
+
# shows an empty list rather than leaking a receipt it never decided to share.
|
|
18
|
+
# A receipt the viewer may not see is NOT FOUND, never forbidden: a 403 tells
|
|
19
|
+
# an outsider that the id they guessed exists, and existence is itself
|
|
20
|
+
# information about someone.
|
|
21
|
+
class ReceiptsController < ApplicationController
|
|
22
|
+
before_action :require_clickwrap_actor
|
|
23
|
+
|
|
24
|
+
# An honest page size rather than an unbounded query. A production actor can
|
|
25
|
+
# accumulate years of retained history even when optional annex data is
|
|
26
|
+
# disposed on a separate schedule.
|
|
27
|
+
PER_PAGE = 50
|
|
28
|
+
|
|
29
|
+
# How many of the viewer's own rows this screen will read looking for
|
|
30
|
+
# PER_PAGE it may show. The host's callback is Ruby, not SQL, so the
|
|
31
|
+
# database cannot apply it and something has to bound the search. A viewer
|
|
32
|
+
# whose first thousand events are all unreadable to them is a host
|
|
33
|
+
# authorization question, not a paging question.
|
|
34
|
+
AUTHORIZATION_SCAN_LIMIT = 1_000
|
|
35
|
+
|
|
36
|
+
BATCH_SIZE = 100
|
|
37
|
+
|
|
38
|
+
def index
|
|
39
|
+
@events = authorized_page
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def show
|
|
43
|
+
@event = find_readable_event
|
|
44
|
+
return head :not_found if @event.nil?
|
|
45
|
+
|
|
46
|
+
@receipt = @event.receipt
|
|
47
|
+
|
|
48
|
+
respond_to do |format|
|
|
49
|
+
format.html
|
|
50
|
+
format.json { render json: @receipt.to_canonical_json }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Authorize, THEN paginate. Taking PER_PAGE rows and filtering afterwards
|
|
57
|
+
# renders an empty page whenever the viewer's newest fifty events happen to
|
|
58
|
+
# be ones the host will not show them — while readable receipts sit at row
|
|
59
|
+
# fifty-one. "Your receipts are gone" is a bad thing for this screen to say
|
|
60
|
+
# by accident.
|
|
61
|
+
#
|
|
62
|
+
# The actor is eager-loaded because the conventional callback compares
|
|
63
|
+
# `controller.current_user == receipt.actor`, which is one query per row
|
|
64
|
+
# otherwise. Reading in batches keeps that at a handful of queries for the
|
|
65
|
+
# whole page instead of one per receipt.
|
|
66
|
+
def authorized_page
|
|
67
|
+
authorized = []
|
|
68
|
+
scanned = 0
|
|
69
|
+
|
|
70
|
+
while authorized.length < page_size && scanned < authorization_scan_limit
|
|
71
|
+
batch = own_events.includes(:actor).offset(scanned).limit(batch_size).to_a
|
|
72
|
+
break if batch.empty?
|
|
73
|
+
|
|
74
|
+
authorized.concat(batch.select { |event| authorized_to_read?(event) })
|
|
75
|
+
scanned += batch.length
|
|
76
|
+
break if batch.length < batch_size
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
authorized.first(page_size)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Readers rather than bare constants so a host that ejects this controller
|
|
83
|
+
# can page it differently by overriding one method.
|
|
84
|
+
def page_size = PER_PAGE
|
|
85
|
+
def batch_size = BATCH_SIZE
|
|
86
|
+
def authorization_scan_limit = AUTHORIZATION_SCAN_LIMIT
|
|
87
|
+
|
|
88
|
+
# The viewer's own events, newest first. Scoping by the actor reference the
|
|
89
|
+
# evidence itself carries — rather than by a foreign key that a deleted
|
|
90
|
+
# account would take with it — is what keeps this list working after a row
|
|
91
|
+
# has gone.
|
|
92
|
+
def own_events
|
|
93
|
+
Event.for_actor(actor_reference)
|
|
94
|
+
.order(recorded_at_by_server: :desc, id: :desc)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def actor_reference
|
|
98
|
+
Reference.actor(clickwrap_current_actor)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def find_readable_event
|
|
102
|
+
event = Event.find_by(id: params[:id])
|
|
103
|
+
return nil if event.nil?
|
|
104
|
+
return nil unless authorized_to_read?(event)
|
|
105
|
+
|
|
106
|
+
event
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The host's answer, treated as a plain yes/no. The default is no, so a host
|
|
110
|
+
# that has not made this decision yet cannot accidentally publish anything.
|
|
111
|
+
def authorized_to_read?(event)
|
|
112
|
+
!!Clickwrap.config.authorize_receipt_access_with.call(self, event.receipt)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# Withdrawing one consent purpose.
|
|
5
|
+
#
|
|
6
|
+
# This screen is deliberately as short as the screen that granted the consent
|
|
7
|
+
# in the first place: one page, one control, one press. Withdrawal must be no
|
|
8
|
+
# harder than granting was — no support ticket, no email, no re-authentication
|
|
9
|
+
# the grant did not require, no confirmation maze. A gem that made taking
|
|
10
|
+
# consent back harder than giving it would be building the exact pattern it
|
|
11
|
+
# exists to prevent, so this controller has nothing in it but the two actions.
|
|
12
|
+
#
|
|
13
|
+
# Withdrawal APPENDS an event. It never deletes or edits the historical grant:
|
|
14
|
+
# what was true then stays recorded, and what is true now is that the person
|
|
15
|
+
# changed their mind. Only consent is withdrawable — withdrawing future
|
|
16
|
+
# processing does not rewrite a past agreement or a factual declaration.
|
|
17
|
+
class WithdrawalsController < ApplicationController
|
|
18
|
+
before_action :require_clickwrap_actor
|
|
19
|
+
before_action :find_purpose
|
|
20
|
+
before_action :remember_return_destination
|
|
21
|
+
|
|
22
|
+
def new; end
|
|
23
|
+
|
|
24
|
+
def create
|
|
25
|
+
Clickwrap.withdraw!(
|
|
26
|
+
@purpose_key,
|
|
27
|
+
actor: clickwrap_current_actor,
|
|
28
|
+
tenant: clickwrap_current_tenant,
|
|
29
|
+
http_request: request,
|
|
30
|
+
because: t("clickwrap.withdrawals.recorded_reason")
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
redirect_to @return_to, allow_other_host: false, notice: t("clickwrap.withdrawals.confirmed")
|
|
34
|
+
rescue AlreadyWithdrawnError
|
|
35
|
+
# Pressing the button twice is not an error worth showing a person. The
|
|
36
|
+
# purpose is withdrawn either way, which is what they asked for.
|
|
37
|
+
redirect_to @return_to, allow_other_host: false, notice: t("clickwrap.withdrawals.already_withdrawn")
|
|
38
|
+
rescue NotWithdrawableError => error
|
|
39
|
+
flash.now[:alert] = error.message
|
|
40
|
+
render :new, status: 422
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# The purpose, not the policy: consent is purpose-specific, and a person
|
|
46
|
+
# withdrawing product updates is not withdrawing everything they ever did on
|
|
47
|
+
# the same screen.
|
|
48
|
+
def find_purpose
|
|
49
|
+
@purpose_key = params[:purpose_key].to_s
|
|
50
|
+
|
|
51
|
+
head :not_found if @purpose_key.empty?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Browser-supplied navigation, checked the same way everywhere: a relative
|
|
55
|
+
# path on this host, or this engine's own root.
|
|
56
|
+
def remember_return_destination
|
|
57
|
+
@return_to = clickwrap_safe_return_to(params[:return_to], fallback: clickwrap_engine_routes.root_path)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# View helpers, available BOTH inside the engine's own views and in the HOST
|
|
5
|
+
# app's views (mixed into ActionView by the hook at the bottom of this file,
|
|
6
|
+
# the same pattern the chats and moderate gems use).
|
|
7
|
+
#
|
|
8
|
+
# Everything here is prefixed `clickwrap_`, because these methods land in
|
|
9
|
+
# every view in the host application and a gem has no business claiming a
|
|
10
|
+
# short name in that namespace.
|
|
11
|
+
module EngineHelper
|
|
12
|
+
# The standalone capture screen for one policy — the remediation route:
|
|
13
|
+
#
|
|
14
|
+
# <%= link_to "Complete your declaration", clickwrap_capture_path(:contractor_declaration) %>
|
|
15
|
+
#
|
|
16
|
+
# Any extra options become query parameters, which is how a caller passes
|
|
17
|
+
# `return_to:` for a flow that should resume where it left off.
|
|
18
|
+
def clickwrap_capture_path(policy_key, **)
|
|
19
|
+
clickwrap_routes.capture_path(policy_key, **)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A receipt, addressed by the event it belongs to. Takes a receipt, an
|
|
23
|
+
# event, or a bare event id, because all three turn up in host code.
|
|
24
|
+
def clickwrap_receipt_path(receipt, **)
|
|
25
|
+
clickwrap_routes.receipt_path(clickwrap_event_id_for(receipt), **)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Where someone withdraws one consent purpose. Withdrawal is a first-class
|
|
29
|
+
# screen and not a buried mailto: link, because consent that cannot be
|
|
30
|
+
# withdrawn as easily as it was given is not something this gem will keep
|
|
31
|
+
# calling consent.
|
|
32
|
+
def clickwrap_withdrawal_path(purpose_key, **)
|
|
33
|
+
clickwrap_routes.withdrawal_path(purpose_key, **)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The exact published bytes of one document version — what the presentation
|
|
37
|
+
# links to, and what an auditor reads later.
|
|
38
|
+
def clickwrap_document_version_path(version, **)
|
|
39
|
+
identifier = version.respond_to?(:id) ? version.id : version
|
|
40
|
+
|
|
41
|
+
clickwrap_routes.document_version_path(identifier, **)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The gem's bundled stylesheet. Called from the engine's own views; hosts
|
|
45
|
+
# that eject and restyle the views simply stop including it.
|
|
46
|
+
def clickwrap_styles
|
|
47
|
+
stylesheet_link_tag "clickwrap", "data-turbo-track": "reload"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Engine URL helpers that work from EVERY render context:
|
|
51
|
+
#
|
|
52
|
+
# * host views: the mounted proxy (`clickwrap.`) carries the mount prefix
|
|
53
|
+
# baked in at mount time, so URLs come out right;
|
|
54
|
+
# * engine views during requests: the engine's controllers inherit from
|
|
55
|
+
# the host's ApplicationController, so the proxy is available there too;
|
|
56
|
+
# * no mount at all (bare view tests): fall back to the engine's own
|
|
57
|
+
# url_helpers — prefix-less, but nothing better exists without a mount.
|
|
58
|
+
#
|
|
59
|
+
# NOTE: assumes the default mount name (`mount Clickwrap::Engine => "/x"`
|
|
60
|
+
# auto-names the proxy `clickwrap`). A host mounting with `as: :something`
|
|
61
|
+
# overrides this helper.
|
|
62
|
+
def clickwrap_routes
|
|
63
|
+
respond_to?(:clickwrap) ? clickwrap : Clickwrap::Engine.routes.url_helpers
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The host application's own routes, reachable from inside this isolated
|
|
67
|
+
# engine's views — where a bare `some_path` would be resolved against the
|
|
68
|
+
# engine's route set and explode.
|
|
69
|
+
def clickwrap_main_routes
|
|
70
|
+
respond_to?(:main_app) ? main_app : Rails.application.routes.url_helpers
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def clickwrap_event_id_for(receipt)
|
|
76
|
+
return receipt.event_id if receipt.respond_to?(:event_id)
|
|
77
|
+
return receipt.id if receipt.respond_to?(:id)
|
|
78
|
+
|
|
79
|
+
receipt
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Expose the helpers to the HOST app's views (isolated engines don't share
|
|
85
|
+
# helpers automatically). The hook lives HERE, at the bottom of the file that
|
|
86
|
+
# defines the constant — not in an engine initializer — so it's self-resolving:
|
|
87
|
+
# whenever this file loads (eager load, autoload on first use, or the engine's
|
|
88
|
+
# to_prepare touch), the constant already exists by the time the hook can
|
|
89
|
+
# possibly run. Registering it from an initializer instead would blow up at boot
|
|
90
|
+
# in hosts where ActionView is already loaded during initializers (web-console
|
|
91
|
+
# does this), because `include Clickwrap::EngineHelper` would fire before the
|
|
92
|
+
# autoloader is ready.
|
|
93
|
+
if defined?(ActiveSupport)
|
|
94
|
+
ActiveSupport.on_load(:action_view) do
|
|
95
|
+
include Clickwrap::EngineHelper
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
The standalone capture screen: one policy, on a page of its own.
|
|
3
|
+
|
|
4
|
+
The form posts back to the SAME URL it was served from, so a failed
|
|
5
|
+
submission re-renders here — reloadable, shareable, and in the navigation
|
|
6
|
+
context the host intended, with or without JavaScript.
|
|
7
|
+
%>
|
|
8
|
+
<%= clickwrap_styles %>
|
|
9
|
+
|
|
10
|
+
<div class="clickwrap clickwrap-screen">
|
|
11
|
+
<header class="clickwrap-screen__header">
|
|
12
|
+
<h1 class="clickwrap-screen__title"><%= t("clickwrap.captures.title") %></h1>
|
|
13
|
+
<p class="clickwrap-screen__intro"><%= t("clickwrap.captures.intro") %></p>
|
|
14
|
+
</header>
|
|
15
|
+
|
|
16
|
+
<% if flash.now[:alert].present? || flash[:alert].present? %>
|
|
17
|
+
<p class="clickwrap-flash clickwrap-flash--alert" role="alert"><%= flash[:alert] %></p>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
<%= form_with url: clickwrap_routes.capture_path(@presentation.policy_key),
|
|
21
|
+
method: :post,
|
|
22
|
+
class: "clickwrap-screen__form" do %>
|
|
23
|
+
<%= hidden_field_tag :return_to, @return_to, id: nil %>
|
|
24
|
+
<% if @remediation_token.present? %>
|
|
25
|
+
<%= hidden_field_tag :remediation_token, @remediation_token, id: nil %>
|
|
26
|
+
<% end %>
|
|
27
|
+
|
|
28
|
+
<%= render "clickwrap/shared/fields",
|
|
29
|
+
presentation: @presentation,
|
|
30
|
+
errors: clickwrap_errors,
|
|
31
|
+
submit: { text: @presentation.submit_button_text, options: {} },
|
|
32
|
+
wrapper_options: {} %>
|
|
33
|
+
<% end %>
|
|
34
|
+
</div>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
The viewer's own receipts, newest first.
|
|
3
|
+
|
|
4
|
+
Every row here passed the host's `authorize_receipt_access_with` callback. An
|
|
5
|
+
unconfigured host sees an empty list, which is the safe direction to be wrong
|
|
6
|
+
in: a receipt is only shown once somebody decided it should be.
|
|
7
|
+
%>
|
|
8
|
+
<%= clickwrap_styles %>
|
|
9
|
+
|
|
10
|
+
<div class="clickwrap clickwrap-screen">
|
|
11
|
+
<header class="clickwrap-screen__header">
|
|
12
|
+
<h1 class="clickwrap-screen__title"><%= t("clickwrap.receipts.index_title") %></h1>
|
|
13
|
+
</header>
|
|
14
|
+
|
|
15
|
+
<% if @events.any? %>
|
|
16
|
+
<ul class="clickwrap-receipts">
|
|
17
|
+
<% @events.each do |event| %>
|
|
18
|
+
<li class="clickwrap-receipts__item">
|
|
19
|
+
<a class="clickwrap-receipts__link" href="<%= clickwrap_receipt_path(event.id) %>">
|
|
20
|
+
<%= event.policy_key %>
|
|
21
|
+
</a>
|
|
22
|
+
<%# Deliberately a plain UTC timestamp rather than `l()`: many hosts do
|
|
23
|
+
not bundle rails-i18n, and a time recorded by a server is clearest
|
|
24
|
+
when it is displayed without a silent timezone conversion. %>
|
|
25
|
+
<span class="clickwrap-receipts__meta">
|
|
26
|
+
<%= t("clickwrap.receipts.recorded_at_by_server") %>
|
|
27
|
+
<%= event.recorded_at_by_server&.utc&.strftime("%Y-%m-%d %H:%M UTC") %>
|
|
28
|
+
</span>
|
|
29
|
+
<span class="clickwrap-receipts__meta">
|
|
30
|
+
<%= t("clickwrap.event_types.#{event.event_type}", default: event.event_type.humanize) %>
|
|
31
|
+
</span>
|
|
32
|
+
</li>
|
|
33
|
+
<% end %>
|
|
34
|
+
</ul>
|
|
35
|
+
<% else %>
|
|
36
|
+
<p class="clickwrap-empty"><%= t("clickwrap.receipts.empty") %></p>
|
|
37
|
+
<% end %>
|
|
38
|
+
</div>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
One receipt, in human-readable form. The canonical JSON is one link away and
|
|
3
|
+
is the verifiable artifact; this page is the projection of it a person can
|
|
4
|
+
read.
|
|
5
|
+
|
|
6
|
+
The closing note is part of the product, not a disclaimer bolted on: it says
|
|
7
|
+
exactly what this record does and does not show, in plain words, on the same
|
|
8
|
+
page as the record itself.
|
|
9
|
+
%>
|
|
10
|
+
<%= clickwrap_styles %>
|
|
11
|
+
|
|
12
|
+
<div class="clickwrap clickwrap-screen">
|
|
13
|
+
<header class="clickwrap-screen__header">
|
|
14
|
+
<h1 class="clickwrap-screen__title"><%= t("clickwrap.receipts.show_title") %></h1>
|
|
15
|
+
<p class="clickwrap-screen__intro">
|
|
16
|
+
<%= t("clickwrap.receipts.event_id") %>: <code class="clickwrap-code"><%= @event.id %></code>
|
|
17
|
+
</p>
|
|
18
|
+
</header>
|
|
19
|
+
|
|
20
|
+
<dl class="clickwrap-facts">
|
|
21
|
+
<dt><%= t("clickwrap.receipts.policy") %></dt>
|
|
22
|
+
<dd><%= @event.policy_key %></dd>
|
|
23
|
+
|
|
24
|
+
<dt><%= t("clickwrap.receipts.event_type") %></dt>
|
|
25
|
+
<dd><%= t("clickwrap.event_types.#{@event.event_type}", default: @event.event_type.humanize) %></dd>
|
|
26
|
+
|
|
27
|
+
<dt><%= t("clickwrap.receipts.recorded_at_by_server") %></dt>
|
|
28
|
+
<dd><%= @event.recorded_at_by_server&.utc&.strftime("%Y-%m-%d %H:%M:%S UTC") %></dd>
|
|
29
|
+
|
|
30
|
+
<% if @event.presentation_manifest.present? %>
|
|
31
|
+
<dt><%= t("clickwrap.receipts.submit_button_text") %></dt>
|
|
32
|
+
<dd><%= @event.presentation_manifest["submit_button_text"] %></dd>
|
|
33
|
+
|
|
34
|
+
<%# When the offer was one control carrying one composed sentence, the
|
|
35
|
+
acts below say what was recorded and this says what was read. %>
|
|
36
|
+
<% if (sentence = @event.presentation_manifest.dig("combined_control", "sentence")) %>
|
|
37
|
+
<dt><%= t("clickwrap.receipts.combined_sentence") %></dt>
|
|
38
|
+
<dd><%= sentence %></dd>
|
|
39
|
+
<% end %>
|
|
40
|
+
<% end %>
|
|
41
|
+
</dl>
|
|
42
|
+
|
|
43
|
+
<h2 class="clickwrap-screen__subtitle"><%= t("clickwrap.receipts.acts") %></h2>
|
|
44
|
+
<ul class="clickwrap-acts">
|
|
45
|
+
<% @event.statements.each do |statement| %>
|
|
46
|
+
<li class="clickwrap-acts__item">
|
|
47
|
+
<p class="clickwrap-acts__assertion"><%= statement.assertion_text %></p>
|
|
48
|
+
<p class="clickwrap-acts__meta">
|
|
49
|
+
<%= t("clickwrap.kinds.#{statement.kind}", default: statement.kind.humanize) %>
|
|
50
|
+
—
|
|
51
|
+
<%= t("clickwrap.actions.#{statement.action}", default: statement.action.to_s.humanize) %>
|
|
52
|
+
<% if statement.offered_and_not_taken? %>
|
|
53
|
+
— <%= t("clickwrap.receipts.offered_and_not_taken") %>
|
|
54
|
+
<% end %>
|
|
55
|
+
</p>
|
|
56
|
+
</li>
|
|
57
|
+
<% end %>
|
|
58
|
+
</ul>
|
|
59
|
+
|
|
60
|
+
<% if @event.documents.any? %>
|
|
61
|
+
<h2 class="clickwrap-screen__subtitle"><%= t("clickwrap.receipts.documents") %></h2>
|
|
62
|
+
<ul class="clickwrap-documents">
|
|
63
|
+
<% @event.documents.each do |document| %>
|
|
64
|
+
<li class="clickwrap-documents__item">
|
|
65
|
+
<% if document.document_version_id.present? %>
|
|
66
|
+
<%= link_to document.document_key,
|
|
67
|
+
clickwrap_document_version_path(document.document_version_id),
|
|
68
|
+
class: "clickwrap-documents__link",
|
|
69
|
+
target: "_blank",
|
|
70
|
+
rel: "noopener" %>
|
|
71
|
+
<% else %>
|
|
72
|
+
<span class="clickwrap-documents__link"><%= document.document_key %></span>
|
|
73
|
+
<% end %>
|
|
74
|
+
<span class="clickwrap-documents__version">
|
|
75
|
+
<%= t("clickwrap.ui.document_version", version: document.version_label) %>
|
|
76
|
+
(<%= document.locale %>)
|
|
77
|
+
</span>
|
|
78
|
+
<code class="clickwrap-code clickwrap-code--digest"><%= document.source_content_digest %></code>
|
|
79
|
+
</li>
|
|
80
|
+
<% end %>
|
|
81
|
+
</ul>
|
|
82
|
+
<% end %>
|
|
83
|
+
|
|
84
|
+
<p class="clickwrap-screen__actions">
|
|
85
|
+
<%= link_to t("clickwrap.receipts.canonical_json"),
|
|
86
|
+
clickwrap_receipt_path(@event.id, format: :json),
|
|
87
|
+
class: "clickwrap-link" %>
|
|
88
|
+
</p>
|
|
89
|
+
|
|
90
|
+
<p class="clickwrap-note"><%= t("clickwrap.receipts.what_this_records") %></p>
|
|
91
|
+
</div>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
The error summary, at the top of the block where a person looking for what
|
|
3
|
+
went wrong will find it.
|
|
4
|
+
|
|
5
|
+
role="alert" so assistive technology announces it when the failed submission
|
|
6
|
+
re-renders. tabindex="-1" so it can hold focus, and `autofocus` so the browser
|
|
7
|
+
moves focus here on load without a line of JavaScript — this whole file has to
|
|
8
|
+
work when the page never runs a script.
|
|
9
|
+
|
|
10
|
+
Each entry links to the control it is about, so the fix is one press away
|
|
11
|
+
rather than a scroll and a hunt. One control gets one entry, whether it answers
|
|
12
|
+
one statement or a whole composed sentence: several lines pointing at the same
|
|
13
|
+
checkbox would be a list of the page's internals rather than of a person's
|
|
14
|
+
problems. The message is TEXT: the styling underlines it, but nothing here
|
|
15
|
+
depends on a color being seen.
|
|
16
|
+
|
|
17
|
+
Locals: presentation, entries — [{ control_id:, message: }]
|
|
18
|
+
%>
|
|
19
|
+
<% if entries.any? %>
|
|
20
|
+
<div class="clickwrap-error-summary"
|
|
21
|
+
id="<%= "clickwrap_#{presentation.policy_key}_errors" %>"
|
|
22
|
+
role="alert"
|
|
23
|
+
tabindex="-1"
|
|
24
|
+
autofocus>
|
|
25
|
+
<h2 class="clickwrap-error-summary__heading">
|
|
26
|
+
<%= t("clickwrap.ui.error_summary_heading") %>
|
|
27
|
+
</h2>
|
|
28
|
+
|
|
29
|
+
<ul class="clickwrap-error-summary__list">
|
|
30
|
+
<% entries.each do |entry| %>
|
|
31
|
+
<li class="clickwrap-error-summary__item">
|
|
32
|
+
<a class="clickwrap-error-summary__link" href="#<%= entry[:control_id] %>">
|
|
33
|
+
<%= entry[:message] %>
|
|
34
|
+
</a>
|
|
35
|
+
</li>
|
|
36
|
+
<% end %>
|
|
37
|
+
</ul>
|
|
38
|
+
</div>
|
|
39
|
+
<% end %>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
The reference presentation partial.
|
|
3
|
+
|
|
4
|
+
Everything that renders a policy goes through this file: `form.clickwrap`,
|
|
5
|
+
`form.clickwrap_fields`, and the engine's own standalone capture screen. It is
|
|
6
|
+
plain ERB with no framework, no JavaScript, and no dependency beyond Rails'
|
|
7
|
+
own tag helpers, because it has to work unchanged inside a Tailwind app, a
|
|
8
|
+
Bootstrap app, a ViewComponent app, and an app with a hand-written stylesheet.
|
|
9
|
+
|
|
10
|
+
Eject it and it is yours:
|
|
11
|
+
|
|
12
|
+
bin/rails generate clickwrap:views
|
|
13
|
+
|
|
14
|
+
Your copy at app/views/clickwrap/shared/_fields.html.erb shadows this one
|
|
15
|
+
automatically — the helper renders the partial by NAME, never by path.
|
|
16
|
+
|
|
17
|
+
The default shape is ONE line:
|
|
18
|
+
|
|
19
|
+
[ ] I agree to the Terms of Service and I acknowledge the Privacy Policy.
|
|
20
|
+
|
|
21
|
+
The presenter decides whether a policy can be offered that way and signs the
|
|
22
|
+
exact sentence into the manifest when it can. Everything that line could not
|
|
23
|
+
honestly absorb — an optional consent, a recorded yes/no, copy the application
|
|
24
|
+
wrote itself — follows it as a control of its own, and a policy with nothing
|
|
25
|
+
composable is entirely that. Both go through the same statement partial, which
|
|
26
|
+
is why `itemized_statements` is the list to walk and `statements` is not:
|
|
27
|
+
rendering a second control for a statement the line already answers offers a
|
|
28
|
+
choice nobody has.
|
|
29
|
+
|
|
30
|
+
What this partial deliberately does NOT contain is a hidden field carrying a
|
|
31
|
+
server-owned decision. No IP address, no browser user-agent, no geolocation,
|
|
32
|
+
no policy version, no document digest, no validity window, no retention rule.
|
|
33
|
+
The only hidden field here is the signed presentation token, which is the
|
|
34
|
+
server's own statement about what it offered — the browser answers the offer,
|
|
35
|
+
it never writes it.
|
|
36
|
+
|
|
37
|
+
Locals:
|
|
38
|
+
presentation — a Clickwrap::Presenter::Result
|
|
39
|
+
submit — { text:, options: } to render the action here, or nil
|
|
40
|
+
after — markup the host rendered from the presentation itself
|
|
41
|
+
(the `form.clickwrap_fields do |clickwrap|` block), or nil
|
|
42
|
+
errors — { "statement_key" => "message" } from a failed capture
|
|
43
|
+
wrapper_options — HTML options for the wrapper element
|
|
44
|
+
%>
|
|
45
|
+
<%
|
|
46
|
+
errors = (local_assigns[:errors] || {}).to_h { |key, value| [key.to_s, value] }
|
|
47
|
+
submit = local_assigns[:submit]
|
|
48
|
+
after = local_assigns[:after]
|
|
49
|
+
wrapper_options = (local_assigns[:wrapper_options] || {}).symbolize_keys
|
|
50
|
+
wrapper_classes = ["clickwrap", "clickwrap-fields", wrapper_options.delete(:class)].compact
|
|
51
|
+
|
|
52
|
+
combined = presentation.combined
|
|
53
|
+
# One control shows one message, whether it answers one statement or three.
|
|
54
|
+
combined_error = combined && errors.values_at(*combined.statement_keys).compact.first
|
|
55
|
+
controls = [combined, *presentation.itemized_statements].compact
|
|
56
|
+
|
|
57
|
+
error_entries = controls.filter_map do |control|
|
|
58
|
+
message = control.equal?(combined) ? combined_error : errors[control.key]
|
|
59
|
+
next if message.blank?
|
|
60
|
+
|
|
61
|
+
{ control_id: control.control_id, message: Array(message).join(" ") }
|
|
62
|
+
end
|
|
63
|
+
%>
|
|
64
|
+
<%= tag.div(**wrapper_options, class: wrapper_classes) do %>
|
|
65
|
+
<%= render "clickwrap/shared/error_summary",
|
|
66
|
+
presentation: presentation,
|
|
67
|
+
entries: error_entries %>
|
|
68
|
+
|
|
69
|
+
<%# The server's statement of what it offered, signed and short-lived. %>
|
|
70
|
+
<%= hidden_field_tag "clickwrap_submission[presentation_token]", presentation.token, id: nil %>
|
|
71
|
+
|
|
72
|
+
<div class="clickwrap-statements">
|
|
73
|
+
<% controls.each do |control| %>
|
|
74
|
+
<%= render "clickwrap/shared/statement",
|
|
75
|
+
statement: control,
|
|
76
|
+
error: control.equal?(combined) ? combined_error : errors[control.key] %>
|
|
77
|
+
<% end %>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<%# The action, rendered from the same presentation as the controls above it,
|
|
81
|
+
so the words recorded in the manifest are the words on the button. Document
|
|
82
|
+
links are above this point, always: a link that only appears after the call
|
|
83
|
+
to action has already been pressed is not a link to anything. %>
|
|
84
|
+
<% if submit %>
|
|
85
|
+
<div class="clickwrap-actions">
|
|
86
|
+
<%
|
|
87
|
+
button_options = (submit[:options] || {}).symbolize_keys
|
|
88
|
+
button_classes = ["clickwrap-submit", button_options.delete(:class)].compact
|
|
89
|
+
%>
|
|
90
|
+
<%= submit_tag submit[:text], **button_options, class: button_classes %>
|
|
91
|
+
</div>
|
|
92
|
+
<% end %>
|
|
93
|
+
|
|
94
|
+
<%# The host's own action, rendered by its own markup from this same
|
|
95
|
+
presentation. It sits here, after the controls and after the document
|
|
96
|
+
links, for exactly the reason above. %>
|
|
97
|
+
<% if after.present? %>
|
|
98
|
+
<div class="clickwrap-actions"><%= after %></div>
|
|
99
|
+
<% end %>
|
|
100
|
+
<% end %>
|