clickwrap 0.0.0 → 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.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +90 -0
  3. data/CHANGELOG.md +612 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +241 -0
  7. data/app/controllers/clickwrap/application_controller.rb +79 -0
  8. data/app/controllers/clickwrap/captures_controller.rb +145 -0
  9. data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
  10. data/app/controllers/clickwrap/receipts_controller.rb +115 -0
  11. data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
  12. data/app/helpers/clickwrap/engine_helper.rb +97 -0
  13. data/app/views/clickwrap/captures/show.html.erb +34 -0
  14. data/app/views/clickwrap/receipts/index.html.erb +38 -0
  15. data/app/views/clickwrap/receipts/show.html.erb +91 -0
  16. data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
  17. data/app/views/clickwrap/shared/_fields.html.erb +100 -0
  18. data/app/views/clickwrap/shared/_statement.html.erb +105 -0
  19. data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
  20. data/config/locales/en.yml +160 -0
  21. data/config/locales/es.yml +138 -0
  22. data/config/routes.rb +41 -0
  23. data/exe/clickwrap +374 -0
  24. data/guides/README.md +30 -0
  25. data/guides/accessibility.md +249 -0
  26. data/guides/consent-and-lifecycle.md +299 -0
  27. data/guides/integrating.md +610 -0
  28. data/guides/integrity.md +212 -0
  29. data/guides/migrating.md +335 -0
  30. data/guides/naming.md +320 -0
  31. data/guides/organizations.md +320 -0
  32. data/guides/receipts-and-verification.md +415 -0
  33. data/guides/request-evidence.md +512 -0
  34. data/guides/retention-and-legal-holds.md +438 -0
  35. data/lib/clickwrap/actor_proxy.rb +147 -0
  36. data/lib/clickwrap/anonymous_actor.rb +47 -0
  37. data/lib/clickwrap/authority.rb +174 -0
  38. data/lib/clickwrap/canonical_json.rb +216 -0
  39. data/lib/clickwrap/capture/event_builder.rb +220 -0
  40. data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
  41. data/lib/clickwrap/capture.rb +650 -0
  42. data/lib/clickwrap/configuration.rb +1129 -0
  43. data/lib/clickwrap/controller_helpers.rb +758 -0
  44. data/lib/clickwrap/current_state.rb +282 -0
  45. data/lib/clickwrap/digest.rb +125 -0
  46. data/lib/clickwrap/doctor.rb +418 -0
  47. data/lib/clickwrap/document_definition.rb +255 -0
  48. data/lib/clickwrap/document_renderer.rb +83 -0
  49. data/lib/clickwrap/document_renderers/markdown.rb +175 -0
  50. data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
  51. data/lib/clickwrap/dsl/policy_builder.rb +462 -0
  52. data/lib/clickwrap/dsl/retention_builder.rb +89 -0
  53. data/lib/clickwrap/durable_commit_callback.rb +37 -0
  54. data/lib/clickwrap/engine.rb +184 -0
  55. data/lib/clickwrap/errors.rb +181 -0
  56. data/lib/clickwrap/form_builder_extensions.rb +341 -0
  57. data/lib/clickwrap/front_matter.rb +67 -0
  58. data/lib/clickwrap/identifier.rb +112 -0
  59. data/lib/clickwrap/import/external_receipt.rb +241 -0
  60. data/lib/clickwrap/import/fine_print.rb +290 -0
  61. data/lib/clickwrap/import/legacy.rb +450 -0
  62. data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
  63. data/lib/clickwrap/integrity/anchor.rb +130 -0
  64. data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
  65. data/lib/clickwrap/integrity/attestor.rb +221 -0
  66. data/lib/clickwrap/integrity/chain.rb +313 -0
  67. data/lib/clickwrap/integrity/timestamp.rb +143 -0
  68. data/lib/clickwrap/ip_geolocation/location.rb +112 -0
  69. data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
  70. data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
  71. data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
  72. data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
  73. data/lib/clickwrap/ip_geolocation.rb +16 -0
  74. data/lib/clickwrap/lifecycle.rb +534 -0
  75. data/lib/clickwrap/linter.rb +382 -0
  76. data/lib/clickwrap/localized_text.rb +101 -0
  77. data/lib/clickwrap/macros.rb +203 -0
  78. data/lib/clickwrap/models/application_record.rb +20 -0
  79. data/lib/clickwrap/models/chain_head.rb +79 -0
  80. data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
  81. data/lib/clickwrap/models/disposition_plan.rb +208 -0
  82. data/lib/clickwrap/models/document.rb +46 -0
  83. data/lib/clickwrap/models/document_version.rb +163 -0
  84. data/lib/clickwrap/models/event.rb +743 -0
  85. data/lib/clickwrap/models/event_document.rb +79 -0
  86. data/lib/clickwrap/models/event_statement.rb +92 -0
  87. data/lib/clickwrap/models/external_action.rb +150 -0
  88. data/lib/clickwrap/models/integrity_attestation.rb +90 -0
  89. data/lib/clickwrap/models/legal_hold.rb +81 -0
  90. data/lib/clickwrap/models/policy_revision.rb +115 -0
  91. data/lib/clickwrap/models/presentation.rb +59 -0
  92. data/lib/clickwrap/models/receipt_access.rb +53 -0
  93. data/lib/clickwrap/models/recording_sequence.rb +21 -0
  94. data/lib/clickwrap/models/request_evidence.rb +378 -0
  95. data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
  96. data/lib/clickwrap/models/statement_state.rb +130 -0
  97. data/lib/clickwrap/pending_receipt.rb +177 -0
  98. data/lib/clickwrap/policy.rb +283 -0
  99. data/lib/clickwrap/presentation_manifest.rb +210 -0
  100. data/lib/clickwrap/presenter.rb +716 -0
  101. data/lib/clickwrap/privacy.rb +419 -0
  102. data/lib/clickwrap/protected_outcome.rb +120 -0
  103. data/lib/clickwrap/receipt.rb +606 -0
  104. data/lib/clickwrap/receipt_html.rb +235 -0
  105. data/lib/clickwrap/receipt_verifier.rb +978 -0
  106. data/lib/clickwrap/reference.rb +44 -0
  107. data/lib/clickwrap/registration.rb +236 -0
  108. data/lib/clickwrap/registry.rb +54 -0
  109. data/lib/clickwrap/remediation_token.rb +155 -0
  110. data/lib/clickwrap/request_evidence_extractor.rb +590 -0
  111. data/lib/clickwrap/request_evidence_policy.rb +261 -0
  112. data/lib/clickwrap/retention/applier.rb +231 -0
  113. data/lib/clickwrap/retention/disposition.rb +221 -0
  114. data/lib/clickwrap/retention/planner.rb +502 -0
  115. data/lib/clickwrap/retention_class.rb +97 -0
  116. data/lib/clickwrap/reviewed_text.rb +28 -0
  117. data/lib/clickwrap/schema_requirements.rb +196 -0
  118. data/lib/clickwrap/services/authorize_external_action.rb +149 -0
  119. data/lib/clickwrap/services/load_policies.rb +69 -0
  120. data/lib/clickwrap/services/publish_documents.rb +251 -0
  121. data/lib/clickwrap/services/validate_policy_references.rb +166 -0
  122. data/lib/clickwrap/statement.rb +248 -0
  123. data/lib/clickwrap/subject_fingerprint.rb +28 -0
  124. data/lib/clickwrap/submission.rb +169 -0
  125. data/lib/clickwrap/system_actor.rb +31 -0
  126. data/lib/clickwrap/test_helpers.rb +676 -0
  127. data/lib/clickwrap/testing.rb +211 -0
  128. data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
  129. data/lib/clickwrap/verification.rb +504 -0
  130. data/lib/clickwrap/version.rb +12 -1
  131. data/lib/clickwrap/view_helpers.rb +190 -0
  132. data/lib/clickwrap/vocabulary.rb +294 -0
  133. data/lib/clickwrap.rb +497 -7
  134. data/lib/generators/clickwrap/document_generator.rb +164 -0
  135. data/lib/generators/clickwrap/hardening_generator.rb +177 -0
  136. data/lib/generators/clickwrap/install_generator.rb +1287 -0
  137. data/lib/generators/clickwrap/link_generator.rb +56 -0
  138. data/lib/generators/clickwrap/policy_generator.rb +118 -0
  139. data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
  140. data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
  141. data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
  142. data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
  143. data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
  144. data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
  145. data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
  146. data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
  147. data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
  148. data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
  149. data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
  150. data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
  151. data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
  152. data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
  153. data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
  154. data/lib/generators/clickwrap/views_generator.rb +101 -0
  155. data/lib/tasks/clickwrap.rake +569 -0
  156. 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 %>