feat hide import before public release

Commit
dacf5a1ac97195d2c51e789739b9a7603ce3ea0f
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/presenter/pages.mli
index b0d7816a..bdc9dcde 100644..100644
@@ -92,6 +92,7 @@
92 92 Dream.request ->
93 93 ?workout_started:int ->
94 94 viewer:View_model.Viewer.t ->
95 Added: import_open:bool ->
95 96 ?username_error:string ->
96 97 ?password_error:string ->
97 98 ?notice:string ->
lib/presenter/pages_profile.ml
index 6c250639..537a432e 100644..100644
@@ -6,8 +6,8 @@
6 6 independent forms: rename the account, and change the password. Each form
7 7 reports its own error inline. [notice] confirms a successful change. Both are
8 8 plain authenticated app-forms, so they work with or without script. *)
9 Removed: let profile request ?workout_started ~viewer ?username_error ?password_error
10 Removed: ?notice () =
9 Added: let profile request ?workout_started ~viewer ~import_open ?username_error
10 Added: ?password_error ?notice () =
11 11 let notice_block =
12 12 match notice with
13 13 | Some message -> [ tag "p" [ class_ "notice" ] [ txt "%s" message ] ]
@@ -177,70 +177,78 @@
177 177 ]
178 178 @ error_block password_error);
179 179 ];
180 Removed: (* Import: a multipart form uploads a native Hevy CSV export. The file
180 Added: ]
181 Added: @ (if import_open then
182 Added: [
183 Added: (* Import: a multipart form uploads a native Hevy CSV export. The file
181 184 field is [csv], the wall-clock offset is [utc_offset_seconds], and an
182 185 optional checkbox overrides duplicate-source rejection. The form is a
183 186 plain multipart POST with its CSRF token, so it needs no script. *)
184 Removed: tag "section"
185 Removed: [ class_ "profile-section" ]
186 Removed: [
187 Removed: tag "h2" [] [ txt "Import" ];
188 Removed: tag "form"
189 Removed: [
190 Removed: action Routes.import_upload;
191 Removed: post_form;
192 Removed: Dream_html.string_attr "enctype" "multipart/form-data";
193 Removed: class_ "profile-form";
194 Removed: ]
195 Removed: [
196 Removed: Dream_html.csrf_tag request;
197 Removed: tag "div"
198 Removed: [ class_ "field" ]
199 Removed: [
200 Removed: tag "label"
201 Removed: [ Dream_html.string_attr "for" "csv" ]
202 Removed: [ txt "Hevy CSV export" ];
203 Removed: void "input"
204 Removed: [
205 Removed: type_ "file";
206 Removed: name "csv";
207 Removed: Dream_html.string_attr "id" "csv";
208 Removed: Dream_html.string_attr "accept" ".csv,text/csv";
209 Removed: required;
210 Removed: ];
211 Removed: ];
212 Removed: tag "div"
213 Removed: [ class_ "field" ]
214 Removed: [
215 Removed: tag "label"
216 Removed: [ Dream_html.string_attr "for" "utc_offset_seconds" ]
217 Removed: [ txt "UTC offset in seconds" ];
218 Removed: void "input"
219 Removed: [
220 Removed: type_ "number";
221 Removed: name "utc_offset_seconds";
222 Removed: Dream_html.string_attr "id" "utc_offset_seconds";
223 Removed: Dream_html.string_attr "value" "0";
224 Removed: required;
225 Removed: ];
226 Removed: ];
227 Removed: tag "div"
228 Removed: [ class_ "field" ]
229 Removed: [
230 Removed: void "input"
231 Removed: [
232 Removed: type_ "checkbox";
233 Removed: name "allow_duplicate";
234 Removed: Dream_html.string_attr "id" "allow_duplicate";
235 Removed: value "true";
236 Removed: ];
237 Removed: tag "label"
238 Removed: [ Dream_html.string_attr "for" "allow_duplicate" ]
239 Removed: [ txt "Import even if this export was imported before" ];
240 Removed: ];
241 Removed: void "input" [ type_ "submit"; value "Upload import" ];
242 Removed: ];
243 Removed: ];
187 Added: tag "section"
188 Added: [ class_ "profile-section" ]
189 Added: [
190 Added: tag "h2" [] [ txt "Import" ];
191 Added: tag "form"
192 Added: [
193 Added: action Routes.import_upload;
194 Added: post_form;
195 Added: Dream_html.string_attr "enctype" "multipart/form-data";
196 Added: class_ "profile-form";
197 Added: ]
198 Added: [
199 Added: Dream_html.csrf_tag request;
200 Added: tag "div"
201 Added: [ class_ "field" ]
202 Added: [
203 Added: tag "label"
204 Added: [ Dream_html.string_attr "for" "csv" ]
205 Added: [ txt "Hevy CSV export" ];
206 Added: void "input"
207 Added: [
208 Added: type_ "file";
209 Added: name "csv";
210 Added: Dream_html.string_attr "id" "csv";
211 Added: Dream_html.string_attr "accept" ".csv,text/csv";
212 Added: required;
213 Added: ];
214 Added: ];
215 Added: tag "div"
216 Added: [ class_ "field" ]
217 Added: [
218 Added: tag "label"
219 Added: [ Dream_html.string_attr "for" "utc_offset_seconds" ]
220 Added: [ txt "UTC offset in seconds" ];
221 Added: void "input"
222 Added: [
223 Added: type_ "number";
224 Added: name "utc_offset_seconds";
225 Added: Dream_html.string_attr "id" "utc_offset_seconds";
226 Added: Dream_html.string_attr "value" "0";
227 Added: required;
228 Added: ];
229 Added: ];
230 Added: tag "div"
231 Added: [ class_ "field" ]
232 Added: [
233 Added: void "input"
234 Added: [
235 Added: type_ "checkbox";
236 Added: name "allow_duplicate";
237 Added: Dream_html.string_attr "id" "allow_duplicate";
238 Added: value "true";
239 Added: ];
240 Added: tag "label"
241 Added: [ Dream_html.string_attr "for" "allow_duplicate" ]
242 Added: [
243 Added: txt "Import even if this export was imported before";
244 Added: ];
245 Added: ];
246 Added: void "input" [ type_ "submit"; value "Upload import" ];
247 Added: ];
248 Added: ];
249 Added: ]
250 Added: else [])
251 Added: @ [
244 252 tag "section"
245 253 [ class_ "profile-section profile-link-section" ]
246 254 [
lib/web/handlers.ml
index 9aab16c3..f106b2e7 100644..100644
@@ -38,7 +38,9 @@
38 38 let routes t =
39 39 Auth.routes t @ Overview.routes t @ Current_workout.routes t
40 40 @ Logbook.routes t @ App_feedback.routes t @ Profile.routes t
41 Removed: @ Settings.routes t @ Import.routes t @ Assets.routes
41 Added: @ Settings.routes t
42 Added: @ (if t.import_open then Import.routes t else [])
43 Added: @ Assets.routes
42 44 @ [ catch_all t ]
43 45
44 46 (* A branded error page for every failure Dream routes here: unmatched paths,
lib/web/handlers.mli
index 22b7ec06..bbf945f6 100644..100644
@@ -12,11 +12,12 @@
12 12 repo:R.t ->
13 13 ?now:(unit -> Recovery.timestamp) ->
14 14 ?registration_open:bool ->
15 Added: ?import_open:bool ->
15 16 unit ->
16 17 t
17 18 (** [registration_open] exposes the public [/register] routes and the sign-in
18 Removed: page's Register link. Defaults to [false]: the deployment runs on
19 Removed: pre-seeded accounts until public sign-up returns. *)
19 Added: page's Register link. [import_open] exposes the Hevy import routes and the
20 Added: profile import form. Both default to [false]. *)
20 21
21 22 val routes : t -> Dream.route list
22 23 (** The route table. Session and CSRF middleware are applied per handler. Wrap
lib/web/handlers_kit.ml
index ffb2fc9a..529cc6d9 100644..100644
@@ -44,17 +44,20 @@
44 44 service : Service.t;
45 45 now : unit -> Recovery.timestamp;
46 46 registration_open : bool;
47 Added: import_open : bool;
47 48 sign_in_limiter : Sign_in_limiter.t;
48 49 }
49 50
50 51 let default_now () =
51 52 Recovery.timestamp_of_unix_seconds (int_of_float (Unix.gettimeofday ()))
52 53
53 Removed: let make ~repo ?(now = default_now) ?(registration_open = false) () =
54 Added: let make ~repo ?(now = default_now) ?(registration_open = false)
55 Added: ?(import_open = false) () =
54 56 {
55 57 service = Service.make ~repo;
56 58 now;
57 59 registration_open;
60 Added: import_open;
58 61 sign_in_limiter = Sign_in_limiter.make ();
59 62 }
60 63
lib/web/handlers_profile.ml
index f196936a..3e8fbb5f 100644..100644
@@ -23,7 +23,7 @@
23 23 html
24 24 (Pages.profile request
25 25 ?workout_started:(workout_started in_progress)
26 Removed: ~viewer ?notice ())
26 Added: ~viewer ~import_open:t.import_open ?notice ())
27 27
28 28 let render_error t trainee request ?username_error ?password_error () =
29 29 let viewer = viewer_of trainee in
@@ -31,7 +31,7 @@
31 31 html ~status:`Bad_Request
32 32 (Pages.profile request
33 33 ?workout_started:(workout_started in_progress)
34 Removed: ~viewer ?username_error ?password_error ())
34 Added: ~viewer ~import_open:t.import_open ?username_error ?password_error ())
35 35
36 36 let change_username t trainee request =
37 37 decode_form Decode.profile_username request >>= function
test/web/import.ml
index 3c6ef536..62afeebd 100644..100644
@@ -34,7 +34,7 @@
34 34 ( "a valid native export uploads to a stored batch with immutable source",
35 35 `Quick,
36 36 fun () ->
37 Removed: let c, repo = client_with_repo () in
37 Added: let c, repo = client_with_repo ~import_open:true () in
38 38 let token = signed_in_profile_token c in
39 39 let trainee = the_trainee repo ~username:"lifter" in
40 40 let source =
@@ -62,7 +62,7 @@
62 62 ( "a duplicate upload is refused, the override stores a second batch",
63 63 `Quick,
64 64 fun () ->
65 Removed: let c, repo = client_with_repo () in
65 Added: let c, repo = client_with_repo ~import_open:true () in
66 66 let token = signed_in_profile_token c in
67 67 let trainee = the_trainee repo ~username:"lifter" in
68 68 let source =
@@ -113,7 +113,7 @@
113 113 ( "warmup and unsupported rows persist as warnings, not sets",
114 114 `Quick,
115 115 fun () ->
116 Removed: let c, repo = client_with_repo () in
116 Added: let c, repo = client_with_repo ~import_open:true () in
117 117 let token = signed_in_profile_token c in
118 118 let trainee = the_trainee repo ~username:"lifter" in
119 119 let source =
@@ -147,7 +147,7 @@
147 147 ( "map, confirm, then promote writes an imported record",
148 148 `Quick,
149 149 fun () ->
150 Removed: let c, repo = client_with_repo () in
150 Added: let c, repo = client_with_repo ~import_open:true () in
151 151 let token = signed_in_profile_token c in
152 152 let trainee = the_trainee repo ~username:"lifter" in
153 153 (* A single normal set: it needs both a mapping and a normal-set
@@ -210,7 +210,7 @@
210 210 (* A drop set is discarded on import — HD1 keeps only the drive to
211 211 failure. A workout of nothing but a drop set therefore has no
212 212 promotable set and writes no record. *)
213 Removed: let c, repo = client_with_repo () in
213 Added: let c, repo = client_with_repo ~import_open:true () in
214 214 let token = signed_in_profile_token c in
215 215 let trainee = the_trainee repo ~username:"lifter" in
216 216 let source =
@@ -261,7 +261,7 @@
261 261 the blocked one",
262 262 `Quick,
263 263 fun () ->
264 Removed: let c, repo = client_with_repo () in
264 Added: let c, repo = client_with_repo ~import_open:true () in
265 265 let token = signed_in_profile_token c in
266 266 let trainee = the_trainee repo ~username:"lifter" in
267 267 (* Two workouts: one ready failure set, one dropset-only workout
@@ -329,7 +329,7 @@
329 329 nothing",
330 330 `Quick,
331 331 fun () ->
332 Removed: let c, repo = client_with_repo () in
332 Added: let c, repo = client_with_repo ~import_open:true () in
333 333 let token = signed_in_profile_token c in
334 334 let trainee = the_trainee repo ~username:"lifter" in
335 335 (* Unknown batch id: the review GET is a 404. *)
test/web/profile.ml
index 2017c633..2cbe5834 100644..100644
@@ -115,6 +115,18 @@
115 115 Alcotest.(check int)
116 116 "profile served separately" 200
117 117 (status (get c "/profile")) );
118 Added: ( "the initial release hides the import",
119 Added: `Quick,
120 Added: fun () ->
121 Added: let c = client ~import_open:false () in
122 Added: let _ = sign_in_new c in
123 Added: let profile = body (get c "/profile") in
124 Added: Alcotest.(check bool)
125 Added: "profile offers no upload" true
126 Added: (Option.is_none (index_from ~needle:"Upload import" profile 0));
127 Added: Alcotest.(check int)
128 Added: "import route is not served" 404
129 Added: (status (post c "/profile/import" [])) );
118 130 ( "the settings theme form redirects after a valid update",
119 131 `Quick,
120 132 fun () ->
test/web/support.ml
index cd63adea..aeceb054 100644..100644
@@ -12,9 +12,10 @@
12 12 (* A fresh, fully wired application: secret, in-memory sessions, routes.
13 13 Registration is opened here so the auth-flow tests can create trainees via
14 14 [/register]; production keeps it closed. *)
15 Removed: let app () =
15 Added: let app ?(import_open = true) () =
16 16 let handlers =
17 Removed: Handlers.make ~repo:(Test_repo.create ()) ~registration_open:true ()
17 Added: Handlers.make ~repo:(Test_repo.create ()) ~registration_open:true
18 Added: ~import_open ()
18 19 in
19 20 Dream.memory_sessions @@ Handlers.security_headers
20 21 @@ Dream.router (Handlers.routes handlers)
@@ -41,7 +42,7 @@
41 42
42 43 type client = { app : Dream.handler; mutable jar : string list }
43 44
44 Removed: let client () = { app = app (); jar = [] }
45 Added: let client ?(import_open = true) () = { app = app ~import_open (); jar = [] }
45 46
46 47 let cookie_header client =
47 48 if client.jar = [] then [] else [ ("Cookie", String.concat "; " client.jar) ]
@@ -194,9 +195,9 @@
194 195 (* A client that hands back its repository, so a test can assert the domain
195 196 state the flow produced — the log, the stored feedback, the renamed account —
196 197 rather than reading it back out of rendered HTML. *)
197 Removed: let client_with_repo () =
198 Added: let client_with_repo ?(import_open = true) () =
198 199 let repo = Test_repo.create () in
199 Removed: let handlers = Handlers.make ~repo ~registration_open:true () in
200 Added: let handlers = Handlers.make ~repo ~registration_open:true ~import_open () in
200 201 let app =
201 202 Dream.memory_sessions @@ Dream.router (Handlers.routes handlers) |> fun h ->
202 203 Dream.set_secret "test-secret-value" h