From be71f04838989a6bfa86a5d2a4e44868895ae09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Diedrich?= Date: Wed, 18 Jan 2023 16:13:51 +0100 Subject: [PATCH] this is a really good first commit --- .formatter.exs | 7 +- .gitignore | 3 - README.md | 17 +- assets/css/app.css | 159 ----- assets/css/phoenix.css | 101 --- assets/js/app.js | 5 +- assets/package.json | 11 - assets/pnpm-lock.yaml | 553 --------------- assets/tailwind.config.js | 27 +- assets/vendor/topbar.js | 16 +- config/config.exs | 24 +- config/dev.exs | 16 +- config/prod.exs | 43 +- config/runtime.exs | 32 + config/test.exs | 7 +- lib/something_erlang/accounts.ex | 32 +- lib/something_erlang/accounts/user.ex | 38 +- lib/something_erlang/application.ex | 8 +- lib/something_erlang/forums.ex | 106 --- lib/something_erlang/forums/thread.ex | 18 - lib/something_erlang_web.ex | 141 ++-- .../components/core_components.ex | 633 ++++++++++++++++++ .../components/layouts.ex | 5 + .../components/layouts/app.html.heex | 49 ++ .../components/layouts/root.html.heex | 38 ++ .../controllers/error_html.ex | 19 + .../controllers/error_json.ex | 15 + .../controllers/page_controller.ex | 29 +- .../controllers/page_html.ex | 5 + .../controllers/page_html/default.html.heex | 236 +++++++ .../controllers/page_html/home.html.heex | 3 + .../user_confirmation_controller.ex | 56 -- .../user_registration_controller.ex | 30 - .../user_reset_password_controller.ex | 58 -- .../controllers/user_session_controller.ex | 25 +- .../controllers/user_settings_controller.ex | 90 --- lib/something_erlang_web/endpoint.ex | 5 +- lib/something_erlang_web/icons.ex | 40 -- .../live/bookmarks_live/show.ex | 31 - .../live/bookmarks_live/show.html.heex | 16 - lib/something_erlang_web/live/live_helpers.ex | 60 -- .../{thread_live/show.ex => thread_live.ex} | 110 +-- .../live/thread_live/form_component.ex | 55 -- .../live/thread_live/form_component.html.heex | 24 - .../live/thread_live/index.ex | 46 -- .../live/thread_live/index.html.heex | 41 -- .../live/thread_live/show.html.heex | 26 - .../user_confirmation_instructions_live.ex | 45 ++ .../live/user_confirmation_live.ex | 58 ++ .../live/user_forgot_password_live.ex | 51 ++ .../live/user_live_auth.ex | 16 - .../live/user_login_live.ex | 49 ++ .../live/user_registration_live.ex | 74 ++ .../live/user_reset_password_live.ex | 87 +++ .../live/user_settings_live.ex | 161 +++++ lib/something_erlang_web/router.ex | 93 +-- lib/something_erlang_web/telemetry.ex | 21 + .../templates/layout/_user_menu.html.heex | 17 - .../templates/layout/app.html.heex | 5 - .../templates/layout/live.html.heex | 11 - .../templates/layout/root.html.heex | 38 -- .../templates/page/index.html.heex | 5 - .../user_confirmation/edit.html.heex | 12 - .../templates/user_confirmation/new.html.heex | 15 - .../templates/user_registration/new.html.heex | 26 - .../user_reset_password/edit.html.heex | 26 - .../user_reset_password/new.html.heex | 15 - .../templates/user_session/new.html.heex | 27 - .../templates/user_settings/edit.html.heex | 79 --- .../{controllers => }/user_auth.ex | 102 ++- .../views/error_helpers.ex | 47 -- lib/something_erlang_web/views/error_view.ex | 16 - lib/something_erlang_web/views/layout_view.ex | 7 - lib/something_erlang_web/views/page_view.ex | 3 - .../views/user_confirmation_view.ex | 3 - .../views/user_registration_view.ex | 3 - .../views/user_reset_password_view.ex | 3 - .../views/user_session_view.ex | 3 - .../views/user_settings_view.ex | 3 - mix.exs | 30 +- mix.lock | 73 +- notebooks/client.livemd | 7 - notebooks/something_erlang.livemd | 80 --- priv/gettext/errors.pot | 33 +- .../20220523092454_create_threads.exs | 12 - .../20220718094805_users_add_sadata.exs | 10 - ...230118110156_create_users_auth_tables.exs} | 0 ...vicon-a8ca4e3a2bb8fea46a9ee9e102e7d3eb.ico | Bin 1258 -> 0 bytes ...oenix-5bd99a0d17dd41bc9d9bf6840abcc089.png | Bin 13900 -> 0 bytes priv/static/images/phoenix.png | Bin 13900 -> 0 bytes ...obots-9e2c81b0855bbff2baa8371bc4a78186.txt | 5 - ...ts-9e2c81b0855bbff2baa8371bc4a78186.txt.gz | Bin 164 -> 0 bytes priv/static/robots.txt.gz | Bin 164 -> 0 bytes test/something_erlang/accounts_test.exs | 16 +- test/something_erlang/forums_test.exs | 61 -- .../controllers/error_html_test.exs | 14 + .../controllers/error_json_test.exs | 12 + .../controllers/page_controller_test.exs | 4 +- .../user_confirmation_controller_test.exs | 105 --- .../user_registration_controller_test.exs | 54 -- .../user_reset_password_controller_test.exs | 113 ---- .../user_session_controller_test.exs | 81 ++- .../user_settings_controller_test.exs | 129 ---- .../live/thread_live_test.exs | 110 --- ...er_confirmation_instructions_live_test.exs | 67 ++ .../live/user_confirmation_live_test.exs | 88 +++ .../live/user_forgot_password_live_test.exs | 63 ++ .../live/user_login_live_test.exs | 87 +++ .../live/user_registration_live_test.exs | 87 +++ .../live/user_reset_password_live_test.exs | 118 ++++ .../live/user_settings_live_test.exs | 210 ++++++ .../{controllers => }/user_auth_test.exs | 116 +++- .../views/error_view_test.exs | 15 - .../views/layout_view_test.exs | 8 - .../views/page_view_test.exs | 3 - test/support/conn_case.ex | 10 +- test/support/fixtures/forums_fixtures.ex | 21 - 117 files changed, 2972 insertions(+), 3100 deletions(-) delete mode 100644 assets/css/phoenix.css delete mode 100644 assets/package.json delete mode 100644 assets/pnpm-lock.yaml delete mode 100644 lib/something_erlang/forums.ex delete mode 100644 lib/something_erlang/forums/thread.ex create mode 100644 lib/something_erlang_web/components/core_components.ex create mode 100644 lib/something_erlang_web/components/layouts.ex create mode 100644 lib/something_erlang_web/components/layouts/app.html.heex create mode 100644 lib/something_erlang_web/components/layouts/root.html.heex create mode 100644 lib/something_erlang_web/controllers/error_html.ex create mode 100644 lib/something_erlang_web/controllers/error_json.ex create mode 100644 lib/something_erlang_web/controllers/page_html.ex create mode 100644 lib/something_erlang_web/controllers/page_html/default.html.heex create mode 100644 lib/something_erlang_web/controllers/page_html/home.html.heex delete mode 100644 lib/something_erlang_web/controllers/user_confirmation_controller.ex delete mode 100644 lib/something_erlang_web/controllers/user_registration_controller.ex delete mode 100644 lib/something_erlang_web/controllers/user_reset_password_controller.ex delete mode 100644 lib/something_erlang_web/controllers/user_settings_controller.ex delete mode 100644 lib/something_erlang_web/icons.ex delete mode 100644 lib/something_erlang_web/live/bookmarks_live/show.ex delete mode 100644 lib/something_erlang_web/live/bookmarks_live/show.html.heex delete mode 100644 lib/something_erlang_web/live/live_helpers.ex rename lib/something_erlang_web/live/{thread_live/show.ex => thread_live.ex} (53%) delete mode 100644 lib/something_erlang_web/live/thread_live/form_component.ex delete mode 100644 lib/something_erlang_web/live/thread_live/form_component.html.heex delete mode 100644 lib/something_erlang_web/live/thread_live/index.ex delete mode 100644 lib/something_erlang_web/live/thread_live/index.html.heex delete mode 100644 lib/something_erlang_web/live/thread_live/show.html.heex create mode 100644 lib/something_erlang_web/live/user_confirmation_instructions_live.ex create mode 100644 lib/something_erlang_web/live/user_confirmation_live.ex create mode 100644 lib/something_erlang_web/live/user_forgot_password_live.ex delete mode 100644 lib/something_erlang_web/live/user_live_auth.ex create mode 100644 lib/something_erlang_web/live/user_login_live.ex create mode 100644 lib/something_erlang_web/live/user_registration_live.ex create mode 100644 lib/something_erlang_web/live/user_reset_password_live.ex create mode 100644 lib/something_erlang_web/live/user_settings_live.ex delete mode 100644 lib/something_erlang_web/templates/layout/_user_menu.html.heex delete mode 100644 lib/something_erlang_web/templates/layout/app.html.heex delete mode 100644 lib/something_erlang_web/templates/layout/live.html.heex delete mode 100644 lib/something_erlang_web/templates/layout/root.html.heex delete mode 100644 lib/something_erlang_web/templates/page/index.html.heex delete mode 100644 lib/something_erlang_web/templates/user_confirmation/edit.html.heex delete mode 100644 lib/something_erlang_web/templates/user_confirmation/new.html.heex delete mode 100644 lib/something_erlang_web/templates/user_registration/new.html.heex delete mode 100644 lib/something_erlang_web/templates/user_reset_password/edit.html.heex delete mode 100644 lib/something_erlang_web/templates/user_reset_password/new.html.heex delete mode 100644 lib/something_erlang_web/templates/user_session/new.html.heex delete mode 100644 lib/something_erlang_web/templates/user_settings/edit.html.heex rename lib/something_erlang_web/{controllers => }/user_auth.ex (59%) delete mode 100644 lib/something_erlang_web/views/error_helpers.ex delete mode 100644 lib/something_erlang_web/views/error_view.ex delete mode 100644 lib/something_erlang_web/views/layout_view.ex delete mode 100644 lib/something_erlang_web/views/page_view.ex delete mode 100644 lib/something_erlang_web/views/user_confirmation_view.ex delete mode 100644 lib/something_erlang_web/views/user_registration_view.ex delete mode 100644 lib/something_erlang_web/views/user_reset_password_view.ex delete mode 100644 lib/something_erlang_web/views/user_session_view.ex delete mode 100644 lib/something_erlang_web/views/user_settings_view.ex delete mode 100644 notebooks/client.livemd delete mode 100644 notebooks/something_erlang.livemd delete mode 100644 priv/repo/migrations/20220523092454_create_threads.exs delete mode 100644 priv/repo/migrations/20220718094805_users_add_sadata.exs rename priv/repo/migrations/{20220523091744_create_users_auth_tables.exs => 20230118110156_create_users_auth_tables.exs} (100%) delete mode 100644 priv/static/favicon-a8ca4e3a2bb8fea46a9ee9e102e7d3eb.ico delete mode 100644 priv/static/images/phoenix-5bd99a0d17dd41bc9d9bf6840abcc089.png delete mode 100644 priv/static/images/phoenix.png delete mode 100644 priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt delete mode 100644 priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz delete mode 100644 priv/static/robots.txt.gz delete mode 100644 test/something_erlang/forums_test.exs create mode 100644 test/something_erlang_web/controllers/error_html_test.exs create mode 100644 test/something_erlang_web/controllers/error_json_test.exs delete mode 100644 test/something_erlang_web/controllers/user_confirmation_controller_test.exs delete mode 100644 test/something_erlang_web/controllers/user_registration_controller_test.exs delete mode 100644 test/something_erlang_web/controllers/user_reset_password_controller_test.exs delete mode 100644 test/something_erlang_web/controllers/user_settings_controller_test.exs delete mode 100644 test/something_erlang_web/live/thread_live_test.exs create mode 100644 test/something_erlang_web/live/user_confirmation_instructions_live_test.exs create mode 100644 test/something_erlang_web/live/user_confirmation_live_test.exs create mode 100644 test/something_erlang_web/live/user_forgot_password_live_test.exs create mode 100644 test/something_erlang_web/live/user_login_live_test.exs create mode 100644 test/something_erlang_web/live/user_registration_live_test.exs create mode 100644 test/something_erlang_web/live/user_reset_password_live_test.exs create mode 100644 test/something_erlang_web/live/user_settings_live_test.exs rename test/something_erlang_web/{controllers => }/user_auth_test.exs (60%) delete mode 100644 test/something_erlang_web/views/error_view_test.exs delete mode 100644 test/something_erlang_web/views/layout_view_test.exs delete mode 100644 test/something_erlang_web/views/page_view_test.exs delete mode 100644 test/support/fixtures/forums_fixtures.ex diff --git a/.formatter.exs b/.formatter.exs index 8a6391c..ef8840c 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,6 @@ [ - import_deps: [:ecto, :phoenix], - inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], - subdirectories: ["priv/*/migrations"] + import_deps: [:ecto, :ecto_sql, :phoenix], + subdirectories: ["priv/*/migrations"], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] ] diff --git a/.gitignore b/.gitignore index d1d0d3e..b74ec75 100644 --- a/.gitignore +++ b/.gitignore @@ -28,9 +28,6 @@ something_erlang-*.tar # Ignore digested assets cache. /priv/static/cache_manifest.json -# Ignore icon repo -/priv/icons - # In case you use Node.js/npm, you want to ignore these. npm-debug.log /assets/node_modules/ diff --git a/README.md b/README.md index 0bdd2c5..2d00214 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,18 @@ # SomethingErlang -Up and running: +To start your Phoenix server: - * `mix deps.get` - * `mix ecto.setup` - * `mix phx.server` + * Run `mix setup` to install and setup dependencies + * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. + +Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). + +## Learn more + + * Official website: https://www.phoenixframework.org/ + * Guides: https://hexdocs.pm/phoenix/overview.html + * Docs: https://hexdocs.pm/phoenix + * Forum: https://elixirforum.com/c/phoenix-forum + * Source: https://github.com/phoenixframework/phoenix diff --git a/assets/css/app.css b/assets/css/app.css index 2b10b32..378c8f9 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -3,162 +3,3 @@ @import "tailwindcss/utilities"; /* This file is for your main application CSS */ - -body { - @apply bg-base-300 text-[14pt] leading-8 overflow-x-hidden; -} - -.post { - @apply bg-base-200 shadow-md rounded-md mb-4; - @apply grid grid-cols-[1fr] grid-rows-[min-content_1fr_auto]; - @apply sm:grid-cols-[13em_auto] sm:grid-rows-[1fr_auto]; -} -.post :where(article, .userinfo) { - @apply p-4 pb-0 sm:pb-4; -} - -.post .bbc-block { - @apply bg-base-300 p-4 py-2 border-l-2 border-secondary rounded w-full; -} -.post .bbc-block h4 { - @apply text-sm mb-2; -} -.post .bbc-spoiler { @apply bg-black text-black; } -.post .bbc-spoiler img { @apply invisible; } -.post .bbc-spoiler:hover { @apply text-inherit bg-inherit; } -.post .bbc-spoiler:hover img { @apply visible; } -.post .sa-smilie { @apply inline; } -.post iframe { - @apply w-full bg-[brown]; -} -.post .code { @apply mockup-code border-l-0; } -.post .code:before { @apply -ml-[2ch]; } -.post .code pre:before { @apply mr-0; } -.post .code h5 { @apply hidden; } -.post a[href] { @apply link; } -.post .editedby { @apply text-sm italic opacity-70 mt-4; } -.post .title :where(img[src*="gangtags"]) + * { - @apply mb-1; -} - -.pagination i { - @apply h-5; -} - -/* Alerts and form errors used by phx.new */ -.alert { - padding: 15px; - margin-bottom: 20px; - border: 1px solid transparent; - border-radius: 4px; -} -.alert-info { - color: #31708f; - background-color: #d9edf7; - border-color: #bce8f1; -} -.alert-warning { - color: #8a6d3b; - background-color: #fcf8e3; - border-color: #faebcc; -} -.alert-danger { - color: #a94442; - background-color: #f2dede; - border-color: #ebccd1; -} -.alert p { - margin-bottom: 0; -} -.alert:empty { - display: none; -} -.invalid-feedback { - color: #a94442; - display: block; - margin: -1rem 0 2rem; -} - -/* LiveView specific classes for your customization */ -.phx-no-feedback.invalid-feedback, -.phx-no-feedback .invalid-feedback { - display: none; -} - -.phx-click-loading { - opacity: 0.5; - transition: opacity 1s ease-out; -} - -.phx-loading{ - cursor: wait; -} - -.phx-modal { - opacity: 1!important; - position: fixed; - z-index: 1; - left: 0; - top: 0; - width: 100%; - height: 100%; - overflow: auto; - background-color: rgba(0,0,0,0.4); -} - -.phx-modal-content { - background-color: #fefefe; - margin: 15vh auto; - padding: 20px; - border: 1px solid #888; - width: 80%; -} - -.phx-modal-close { - color: #aaa; - float: right; - font-size: 28px; - font-weight: bold; -} - -.phx-modal-close:hover, -.phx-modal-close:focus { - color: black; - text-decoration: none; - cursor: pointer; -} - -.fade-in-scale { - animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys; -} - -.fade-out-scale { - animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys; -} - -.fade-in { - animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys; -} -.fade-out { - animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys; -} - -@keyframes fade-in-scale-keys{ - 0% { scale: 0.95; opacity: 0; } - 100% { scale: 1.0; opacity: 1; } -} - -@keyframes fade-out-scale-keys{ - 0% { scale: 1.0; opacity: 1; } - 100% { scale: 0.95; opacity: 0; } -} - -@keyframes fade-in-keys{ - 0% { opacity: 0; } - 100% { opacity: 1; } -} - -@keyframes fade-out-keys{ - 0% { opacity: 1; } - 100% { opacity: 0; } -} diff --git a/assets/css/phoenix.css b/assets/css/phoenix.css deleted file mode 100644 index 0d59050..0000000 --- a/assets/css/phoenix.css +++ /dev/null @@ -1,101 +0,0 @@ -/* Includes some default style for the starter application. - * This can be safely deleted to start fresh. - */ - -/* Milligram v1.4.1 https://milligram.github.io - * Copyright (c) 2020 CJ Patoilo Licensed under the MIT license - */ - -*,*:after,*:before{box-sizing:inherit}html{box-sizing:border-box;font-size:62.5%}body{color:#000000;font-family:'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;font-size:1.6em;font-weight:300;letter-spacing:.01em;line-height:1.6}blockquote{border-left:0.3rem solid #d1d1d1;margin-left:0;margin-right:0;padding:1rem 1.5rem}blockquote *:last-child{margin-bottom:0}.button,button,input[type='button'],input[type='reset'],input[type='submit']{background-color:#0069d9;border:0.1rem solid #0069d9;border-radius:.4rem;color:#fff;cursor:pointer;display:inline-block;font-size:1.1rem;font-weight:700;height:3.8rem;letter-spacing:.1rem;line-height:3.8rem;padding:0 3.0rem;text-align:center;text-decoration:none;text-transform:uppercase;white-space:nowrap}.button:focus,.button:hover,button:focus,button:hover,input[type='button']:focus,input[type='button']:hover,input[type='reset']:focus,input[type='reset']:hover,input[type='submit']:focus,input[type='submit']:hover{background-color:#606c76;border-color:#606c76;color:#fff;outline:0}.button[disabled],button[disabled],input[type='button'][disabled],input[type='reset'][disabled],input[type='submit'][disabled]{cursor:default;opacity:.5}.button[disabled]:focus,.button[disabled]:hover,button[disabled]:focus,button[disabled]:hover,input[type='button'][disabled]:focus,input[type='button'][disabled]:hover,input[type='reset'][disabled]:focus,input[type='reset'][disabled]:hover,input[type='submit'][disabled]:focus,input[type='submit'][disabled]:hover{background-color:#0069d9;border-color:#0069d9}.button.button-outline,button.button-outline,input[type='button'].button-outline,input[type='reset'].button-outline,input[type='submit'].button-outline{background-color:transparent;color:#0069d9}.button.button-outline:focus,.button.button-outline:hover,button.button-outline:focus,button.button-outline:hover,input[type='button'].button-outline:focus,input[type='button'].button-outline:hover,input[type='reset'].button-outline:focus,input[type='reset'].button-outline:hover,input[type='submit'].button-outline:focus,input[type='submit'].button-outline:hover{background-color:transparent;border-color:#606c76;color:#606c76}.button.button-outline[disabled]:focus,.button.button-outline[disabled]:hover,button.button-outline[disabled]:focus,button.button-outline[disabled]:hover,input[type='button'].button-outline[disabled]:focus,input[type='button'].button-outline[disabled]:hover,input[type='reset'].button-outline[disabled]:focus,input[type='reset'].button-outline[disabled]:hover,input[type='submit'].button-outline[disabled]:focus,input[type='submit'].button-outline[disabled]:hover{border-color:inherit;color:#0069d9}.button.button-clear,button.button-clear,input[type='button'].button-clear,input[type='reset'].button-clear,input[type='submit'].button-clear{background-color:transparent;border-color:transparent;color:#0069d9}.button.button-clear:focus,.button.button-clear:hover,button.button-clear:focus,button.button-clear:hover,input[type='button'].button-clear:focus,input[type='button'].button-clear:hover,input[type='reset'].button-clear:focus,input[type='reset'].button-clear:hover,input[type='submit'].button-clear:focus,input[type='submit'].button-clear:hover{background-color:transparent;border-color:transparent;color:#606c76}.button.button-clear[disabled]:focus,.button.button-clear[disabled]:hover,button.button-clear[disabled]:focus,button.button-clear[disabled]:hover,input[type='button'].button-clear[disabled]:focus,input[type='button'].button-clear[disabled]:hover,input[type='reset'].button-clear[disabled]:focus,input[type='reset'].button-clear[disabled]:hover,input[type='submit'].button-clear[disabled]:focus,input[type='submit'].button-clear[disabled]:hover{color:#0069d9}code{background:#f4f5f6;border-radius:.4rem;font-size:86%;margin:0 .2rem;padding:.2rem .5rem;white-space:nowrap}pre{background:#f4f5f6;border-left:0.3rem solid #0069d9;overflow-y:hidden}pre>code{border-radius:0;display:block;padding:1rem 1.5rem;white-space:pre}hr{border:0;border-top:0.1rem solid #f4f5f6;margin:3.0rem 0}input[type='color'],input[type='date'],input[type='datetime'],input[type='datetime-local'],input[type='email'],input[type='month'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],input[type='week'],input:not([type]),textarea,select{-webkit-appearance:none;background-color:transparent;border:0.1rem solid #d1d1d1;border-radius:.4rem;box-shadow:none;box-sizing:inherit;height:3.8rem;padding:.6rem 1.0rem .7rem;width:100%}input[type='color']:focus,input[type='date']:focus,input[type='datetime']:focus,input[type='datetime-local']:focus,input[type='email']:focus,input[type='month']:focus,input[type='number']:focus,input[type='password']:focus,input[type='search']:focus,input[type='tel']:focus,input[type='text']:focus,input[type='url']:focus,input[type='week']:focus,input:not([type]):focus,textarea:focus,select:focus{border-color:#0069d9;outline:0}select{background:url('data:image/svg+xml;utf8,') center right no-repeat;padding-right:3.0rem}select:focus{background-image:url('data:image/svg+xml;utf8,')}select[multiple]{background:none;height:auto}textarea{min-height:6.5rem}label,legend{display:block;font-size:1.6rem;font-weight:700;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type='checkbox'],input[type='radio']{display:inline}.label-inline{display:inline-block;font-weight:normal;margin-left:.5rem}.container{margin:0 auto;max-width:112.0rem;padding:0 2.0rem;position:relative;width:100%}.row{display:flex;flex-direction:column;padding:0;width:100%}.row.row-no-padding{padding:0}.row.row-no-padding>.column{padding:0}.row.row-wrap{flex-wrap:wrap}.row.row-top{align-items:flex-start}.row.row-bottom{align-items:flex-end}.row.row-center{align-items:center}.row.row-stretch{align-items:stretch}.row.row-baseline{align-items:baseline}.row .column{display:block;flex:1 1 auto;margin-left:0;max-width:100%;width:100%}.row .column.column-offset-10{margin-left:10%}.row .column.column-offset-20{margin-left:20%}.row .column.column-offset-25{margin-left:25%}.row .column.column-offset-33,.row .column.column-offset-34{margin-left:33.3333%}.row .column.column-offset-40{margin-left:40%}.row .column.column-offset-50{margin-left:50%}.row .column.column-offset-60{margin-left:60%}.row .column.column-offset-66,.row .column.column-offset-67{margin-left:66.6666%}.row .column.column-offset-75{margin-left:75%}.row .column.column-offset-80{margin-left:80%}.row .column.column-offset-90{margin-left:90%}.row .column.column-10{flex:0 0 10%;max-width:10%}.row .column.column-20{flex:0 0 20%;max-width:20%}.row .column.column-25{flex:0 0 25%;max-width:25%}.row .column.column-33,.row .column.column-34{flex:0 0 33.3333%;max-width:33.3333%}.row .column.column-40{flex:0 0 40%;max-width:40%}.row .column.column-50{flex:0 0 50%;max-width:50%}.row .column.column-60{flex:0 0 60%;max-width:60%}.row .column.column-66,.row .column.column-67{flex:0 0 66.6666%;max-width:66.6666%}.row .column.column-75{flex:0 0 75%;max-width:75%}.row .column.column-80{flex:0 0 80%;max-width:80%}.row .column.column-90{flex:0 0 90%;max-width:90%}.row .column .column-top{align-self:flex-start}.row .column .column-bottom{align-self:flex-end}.row .column .column-center{align-self:center}@media (min-width: 40rem){.row{flex-direction:row;margin-left:-1.0rem;width:calc(100% + 2.0rem)}.row .column{margin-bottom:inherit;padding:0 1.0rem}}a{color:#0069d9;text-decoration:none}a:focus,a:hover{color:#606c76}dl,ol,ul{list-style:none;margin-top:0;padding-left:0}dl dl,dl ol,dl ul,ol dl,ol ol,ol ul,ul dl,ul ol,ul ul{font-size:90%;margin:1.5rem 0 1.5rem 3.0rem}ol{list-style:decimal inside}ul{list-style:circle inside}.button,button,dd,dt,li{margin-bottom:1.0rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:2.5rem}table{border-spacing:0;display:block;overflow-x:auto;text-align:left;width:100%}td,th{border-bottom:0.1rem solid #e1e1e1;padding:1.2rem 1.5rem}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0}@media (min-width: 40rem){table{display:table;overflow-x:initial}}b,strong{font-weight:bold}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;letter-spacing:-.1rem;margin-bottom:2.0rem;margin-top:0}h1{font-size:4.6rem;line-height:1.2}h2{font-size:3.6rem;line-height:1.25}h3{font-size:2.8rem;line-height:1.3}h4{font-size:2.2rem;letter-spacing:-.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}img{max-width:100%}.clearfix:after{clear:both;content:' ';display:table}.float-left{float:left}.float-right{float:right} - -/* General style */ -h1{font-size: 3.6rem; line-height: 1.25} -h2{font-size: 2.8rem; line-height: 1.3} -h3{font-size: 2.2rem; letter-spacing: -.08rem; line-height: 1.35} -h4{font-size: 1.8rem; letter-spacing: -.05rem; line-height: 1.5} -h5{font-size: 1.6rem; letter-spacing: 0; line-height: 1.4} -h6{font-size: 1.4rem; letter-spacing: 0; line-height: 1.2} -pre{padding: 1em;} - -.container{ - margin: 0 auto; - max-width: 80.0rem; - padding: 0 2.0rem; - position: relative; - width: 100% -} -select { - width: auto; -} - -/* Phoenix promo and logo */ -.phx-hero { - text-align: center; - border-bottom: 1px solid #e3e3e3; - background: #eee; - border-radius: 6px; - padding: 3em 3em 1em; - margin-bottom: 3rem; - font-weight: 200; - font-size: 120%; -} -.phx-hero input { - background: #ffffff; -} -.phx-logo { - min-width: 300px; - margin: 1rem; - display: block; -} -.phx-logo img { - width: auto; - display: block; -} - -/* Headers */ -header { - width: 100%; - background: #fdfdfd; - border-bottom: 1px solid #eaeaea; - margin-bottom: 2rem; -} -header section { - align-items: center; - display: flex; - flex-direction: column; - justify-content: space-between; -} -header section :first-child { - order: 2; -} -header section :last-child { - order: 1; -} -header nav ul, -header nav li { - margin: 0; - padding: 0; - display: block; - text-align: right; - white-space: nowrap; -} -header nav ul { - margin: 1rem; - margin-top: 0; -} -header nav a { - display: block; -} - -@media (min-width: 40.0rem) { /* Small devices (landscape phones, 576px and up) */ - header section { - flex-direction: row; - } - header nav ul { - margin: 1rem; - } - .phx-logo { - flex-basis: 527px; - margin: 2rem 1rem; - } -} diff --git a/assets/js/app.js b/assets/js/app.js index bf203ba..44a8122 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,6 +1,3 @@ -// We import the CSS which is extracted to its own file by esbuild. -// Remove this line if you add a your own CSS build pipeline (e.g postcss). - // If you want to use Phoenix channels, run `mix help phx.gen.channel` // to get started and then uncomment the line below. // import "./user_socket.js" @@ -30,7 +27,7 @@ let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToke // Show progress bar on live navigation and form submits topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) -window.addEventListener("phx:page-loading-start", info => topbar.show()) +window.addEventListener("phx:page-loading-start", info => topbar.delayedShow(200)) window.addEventListener("phx:page-loading-stop", info => topbar.hide()) // connect if there are any LiveViews on the page diff --git a/assets/package.json b/assets/package.json deleted file mode 100644 index 20c5750..0000000 --- a/assets/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "dependencies": { - "@tailwindcss/typography": "^0.5.2", - "daisyui": "^2.15.0", - "tailwindcss": "^3.0.24" - }, - "devDependencies": { - "autoprefixer": "^10.4.7", - "postcss": "^8.4.14" - } -} diff --git a/assets/pnpm-lock.yaml b/assets/pnpm-lock.yaml deleted file mode 100644 index 5889817..0000000 --- a/assets/pnpm-lock.yaml +++ /dev/null @@ -1,553 +0,0 @@ -lockfileVersion: 5.4 - -specifiers: - '@tailwindcss/typography': ^0.5.2 - autoprefixer: ^10.4.7 - daisyui: ^2.15.0 - postcss: ^8.4.14 - tailwindcss: ^3.0.24 - -dependencies: - '@tailwindcss/typography': 0.5.2_tailwindcss@3.0.24 - daisyui: 2.15.0_ugi4xkrfysqkt4c4y6hkyfj344 - tailwindcss: 3.0.24_postcss@8.4.14 - -devDependencies: - autoprefixer: 10.4.7_postcss@8.4.14 - postcss: 8.4.14 - -packages: - - /@nodelib/fs.scandir/2.1.5: - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - dev: false - - /@nodelib/fs.stat/2.0.5: - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - dev: false - - /@nodelib/fs.walk/1.2.8: - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.13.0 - dev: false - - /@tailwindcss/typography/0.5.2_tailwindcss@3.0.24: - resolution: {integrity: sha512-coq8DBABRPFcVhVIk6IbKyyHUt7YTEC/C992tatFB+yEx5WGBQrCgsSFjxHUr8AWXphWckadVJbominEduYBqw==} - peerDependencies: - tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || insiders' - dependencies: - lodash.castarray: 4.4.0 - lodash.isplainobject: 4.0.6 - lodash.merge: 4.6.2 - tailwindcss: 3.0.24_postcss@8.4.14 - dev: false - - /acorn-node/1.8.2: - resolution: {integrity: sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==} - dependencies: - acorn: 7.4.1 - acorn-walk: 7.2.0 - xtend: 4.0.2 - dev: false - - /acorn-walk/7.2.0: - resolution: {integrity: sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==} - engines: {node: '>=0.4.0'} - dev: false - - /acorn/7.4.1: - resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} - engines: {node: '>=0.4.0'} - hasBin: true - dev: false - - /anymatch/3.1.2: - resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==} - engines: {node: '>= 8'} - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - dev: false - - /arg/5.0.1: - resolution: {integrity: sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==} - dev: false - - /autoprefixer/10.4.7_postcss@8.4.14: - resolution: {integrity: sha512-ypHju4Y2Oav95SipEcCcI5J7CGPuvz8oat7sUtYj3ClK44bldfvtvcxK6IEK++7rqB7YchDGzweZIBG+SD0ZAA==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - dependencies: - browserslist: 4.20.3 - caniuse-lite: 1.0.30001445 - fraction.js: 4.2.0 - normalize-range: 0.1.2 - picocolors: 1.0.0 - postcss: 8.4.14 - postcss-value-parser: 4.2.0 - - /binary-extensions/2.2.0: - resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} - engines: {node: '>=8'} - dev: false - - /braces/3.0.2: - resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} - engines: {node: '>=8'} - dependencies: - fill-range: 7.0.1 - dev: false - - /browserslist/4.20.3: - resolution: {integrity: sha512-NBhymBQl1zM0Y5dQT/O+xiLP9/rzOIQdKM/eMJBAq7yBgaB6krIYLGejrwVYnSHZdqjscB1SPuAjHwxjvN6Wdg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - dependencies: - caniuse-lite: 1.0.30001445 - electron-to-chromium: 1.4.137 - escalade: 3.1.1 - node-releases: 2.0.4 - picocolors: 1.0.0 - - /camelcase-css/2.0.1: - resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} - engines: {node: '>= 6'} - dev: false - - /caniuse-lite/1.0.30001445: - resolution: {integrity: sha512-8sdQIdMztYmzfTMO6KfLny878Ln9c2M0fc7EH60IjlP4Dc4PiCy7K2Vl3ITmWgOyPgVQKa5x+UP/KqFsxj4mBg==} - - /chokidar/3.5.3: - resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} - engines: {node: '>= 8.10.0'} - dependencies: - anymatch: 3.1.2 - braces: 3.0.2 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.2 - dev: false - - /color-convert/2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - dependencies: - color-name: 1.1.4 - dev: false - - /color-name/1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - dev: false - - /color-string/1.9.1: - resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} - dependencies: - color-name: 1.1.4 - simple-swizzle: 0.2.2 - dev: false - - /color/4.2.3: - resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} - engines: {node: '>=12.5.0'} - dependencies: - color-convert: 2.0.1 - color-string: 1.9.1 - dev: false - - /css-selector-tokenizer/0.8.0: - resolution: {integrity: sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==} - dependencies: - cssesc: 3.0.0 - fastparse: 1.1.2 - dev: false - - /cssesc/3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - dev: false - - /daisyui/2.15.0_ugi4xkrfysqkt4c4y6hkyfj344: - resolution: {integrity: sha512-FvKgt3+sqnpNdh9dop2Md9lNnOsJvJ1GGImKrgA6j/gu9tY0Cdp2x9ftd0Y6RrCbDvgu+1ystobvFkAPOnXAfg==} - peerDependencies: - autoprefixer: ^10.0.2 - postcss: ^8.1.6 - dependencies: - autoprefixer: 10.4.7_postcss@8.4.14 - color: 4.2.3 - css-selector-tokenizer: 0.8.0 - postcss: 8.4.14 - postcss-js: 4.0.0_postcss@8.4.14 - tailwindcss: 3.0.24_postcss@8.4.14 - transitivePeerDependencies: - - ts-node - dev: false - - /defined/1.0.0: - resolution: {integrity: sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=} - dev: false - - /detective/5.2.0: - resolution: {integrity: sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==} - engines: {node: '>=0.8.0'} - hasBin: true - dependencies: - acorn-node: 1.8.2 - defined: 1.0.0 - minimist: 1.2.6 - dev: false - - /didyoumean/1.2.2: - resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - dev: false - - /dlv/1.1.3: - resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - dev: false - - /electron-to-chromium/1.4.137: - resolution: {integrity: sha512-0Rcpald12O11BUogJagX3HsCN3FE83DSqWjgXoHo5a72KUKMSfI39XBgJpgNNxS9fuGzytaFjE06kZkiVFy2qA==} - - /escalade/3.1.1: - resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} - engines: {node: '>=6'} - - /fast-glob/3.2.11: - resolution: {integrity: sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==} - engines: {node: '>=8.6.0'} - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.5 - dev: false - - /fastparse/1.1.2: - resolution: {integrity: sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==} - dev: false - - /fastq/1.13.0: - resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==} - dependencies: - reusify: 1.0.4 - dev: false - - /fill-range/7.0.1: - resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} - engines: {node: '>=8'} - dependencies: - to-regex-range: 5.0.1 - dev: false - - /fraction.js/4.2.0: - resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==} - - /fsevents/2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - requiresBuild: true - dev: false - optional: true - - /function-bind/1.1.1: - resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} - dev: false - - /glob-parent/5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - dependencies: - is-glob: 4.0.3 - dev: false - - /glob-parent/6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - dependencies: - is-glob: 4.0.3 - dev: false - - /has/1.0.3: - resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} - engines: {node: '>= 0.4.0'} - dependencies: - function-bind: 1.1.1 - dev: false - - /is-arrayish/0.3.2: - resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} - dev: false - - /is-binary-path/2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - dependencies: - binary-extensions: 2.2.0 - dev: false - - /is-core-module/2.9.0: - resolution: {integrity: sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==} - dependencies: - has: 1.0.3 - dev: false - - /is-extglob/2.1.1: - resolution: {integrity: sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=} - engines: {node: '>=0.10.0'} - dev: false - - /is-glob/4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - dependencies: - is-extglob: 2.1.1 - dev: false - - /is-number/7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - dev: false - - /lilconfig/2.0.5: - resolution: {integrity: sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==} - engines: {node: '>=10'} - dev: false - - /lodash.castarray/4.4.0: - resolution: {integrity: sha1-wCUTUV4wna3dTCTGDP3c9ZdtkRU=} - dev: false - - /lodash.isplainobject/4.0.6: - resolution: {integrity: sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=} - dev: false - - /lodash.merge/4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - dev: false - - /merge2/1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - dev: false - - /micromatch/4.0.5: - resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} - engines: {node: '>=8.6'} - dependencies: - braces: 3.0.2 - picomatch: 2.3.1 - dev: false - - /minimist/1.2.6: - resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==} - dev: false - - /nanoid/3.3.4: - resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - /node-releases/2.0.4: - resolution: {integrity: sha512-gbMzqQtTtDz/00jQzZ21PQzdI9PyLYqUSvD0p3naOhX4odFji0ZxYdnVwPTxmSwkmxhcFImpozceidSG+AgoPQ==} - - /normalize-path/3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - dev: false - - /normalize-range/0.1.2: - resolution: {integrity: sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=} - engines: {node: '>=0.10.0'} - - /object-hash/3.0.0: - resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} - engines: {node: '>= 6'} - dev: false - - /path-parse/1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - dev: false - - /picocolors/1.0.0: - resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} - - /picomatch/2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - dev: false - - /postcss-js/4.0.0_postcss@8.4.14: - resolution: {integrity: sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==} - engines: {node: ^12 || ^14 || >= 16} - peerDependencies: - postcss: ^8.3.3 - dependencies: - camelcase-css: 2.0.1 - postcss: 8.4.14 - dev: false - - /postcss-load-config/3.1.4_postcss@8.4.14: - resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} - engines: {node: '>= 10'} - peerDependencies: - postcss: '>=8.0.9' - ts-node: '>=9.0.0' - peerDependenciesMeta: - postcss: - optional: true - ts-node: - optional: true - dependencies: - lilconfig: 2.0.5 - postcss: 8.4.14 - yaml: 1.10.2 - dev: false - - /postcss-nested/5.0.6_postcss@8.4.14: - resolution: {integrity: sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.2.14 - dependencies: - postcss: 8.4.14 - postcss-selector-parser: 6.0.10 - dev: false - - /postcss-selector-parser/6.0.10: - resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} - engines: {node: '>=4'} - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - dev: false - - /postcss-value-parser/4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - - /postcss/8.4.14: - resolution: {integrity: sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==} - engines: {node: ^10 || ^12 || >=14} - dependencies: - nanoid: 3.3.4 - picocolors: 1.0.0 - source-map-js: 1.0.2 - - /queue-microtask/1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - dev: false - - /quick-lru/5.1.1: - resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} - engines: {node: '>=10'} - dev: false - - /readdirp/3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - dependencies: - picomatch: 2.3.1 - dev: false - - /resolve/1.22.0: - resolution: {integrity: sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==} - hasBin: true - dependencies: - is-core-module: 2.9.0 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - dev: false - - /reusify/1.0.4: - resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - dev: false - - /run-parallel/1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - dependencies: - queue-microtask: 1.2.3 - dev: false - - /simple-swizzle/0.2.2: - resolution: {integrity: sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=} - dependencies: - is-arrayish: 0.3.2 - dev: false - - /source-map-js/1.0.2: - resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} - engines: {node: '>=0.10.0'} - - /supports-preserve-symlinks-flag/1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - dev: false - - /tailwindcss/3.0.24_postcss@8.4.14: - resolution: {integrity: sha512-H3uMmZNWzG6aqmg9q07ZIRNIawoiEcNFKDfL+YzOPuPsXuDXxJxB9icqzLgdzKNwjG3SAro2h9SYav8ewXNgig==} - engines: {node: '>=12.13.0'} - hasBin: true - peerDependencies: - postcss: ^8.0.9 - dependencies: - arg: 5.0.1 - chokidar: 3.5.3 - color-name: 1.1.4 - detective: 5.2.0 - didyoumean: 1.2.2 - dlv: 1.1.3 - fast-glob: 3.2.11 - glob-parent: 6.0.2 - is-glob: 4.0.3 - lilconfig: 2.0.5 - normalize-path: 3.0.0 - object-hash: 3.0.0 - picocolors: 1.0.0 - postcss: 8.4.14 - postcss-js: 4.0.0_postcss@8.4.14 - postcss-load-config: 3.1.4_postcss@8.4.14 - postcss-nested: 5.0.6_postcss@8.4.14 - postcss-selector-parser: 6.0.10 - postcss-value-parser: 4.2.0 - quick-lru: 5.1.1 - resolve: 1.22.0 - transitivePeerDependencies: - - ts-node - dev: false - - /to-regex-range/5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - dependencies: - is-number: 7.0.0 - dev: false - - /util-deprecate/1.0.2: - resolution: {integrity: sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=} - dev: false - - /xtend/4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - dev: false - - /yaml/1.10.2: - resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} - engines: {node: '>= 6'} - dev: false diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index 349794b..e3bf241 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -1,21 +1,26 @@ // See the Tailwind configuration guide for advanced usage // https://tailwindcss.com/docs/configuration + +const plugin = require("tailwindcss/plugin") + module.exports = { content: [ - './js/**/*.js', - '../lib/*_web.ex', - '../lib/*_web/**/*.*ex' + "./js/**/*.js", + "../lib/*_web.ex", + "../lib/*_web/**/*.*ex" ], theme: { - extend: {}, - }, - daisyui: { - themes: ["winter", "night"], - darkTheme: "night" + extend: { + colors: { + brand: "#FD4F00", + } + }, }, plugins: [ - require('@tailwindcss/forms'), - require('@tailwindcss/typography'), - require("daisyui") + require("@tailwindcss/forms"), + plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])), + plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), + plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), + plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])) ] } diff --git a/assets/vendor/topbar.js b/assets/vendor/topbar.js index 1f62209..4176ede 100644 --- a/assets/vendor/topbar.js +++ b/assets/vendor/topbar.js @@ -1,7 +1,9 @@ /** * @license MIT * topbar 1.0.0, 2021-01-06 - * https://buunguyen.github.io/topbar + * Modifications: + * - add delayedShow(time) (2022-09-21) + * http://buunguyen.github.io/topbar * Copyright (c) 2021 Buu Nguyen */ (function (window, document) { @@ -35,10 +37,11 @@ })(); var canvas, - progressTimerId, - fadeTimerId, currentProgress, showing, + progressTimerId = null, + fadeTimerId = null, + delayTimerId = null, addEvent = function (elem, type, handler) { if (elem.addEventListener) elem.addEventListener(type, handler, false); else if (elem.attachEvent) elem.attachEvent("on" + type, handler); @@ -95,6 +98,11 @@ for (var key in opts) if (options.hasOwnProperty(key)) options[key] = opts[key]; }, + delayedShow: function(time) { + if (showing) return; + if (delayTimerId) return; + delayTimerId = setTimeout(() => topbar.show(), time); + }, show: function () { if (showing) return; showing = true; @@ -125,6 +133,8 @@ return currentProgress; }, hide: function () { + clearTimeout(delayTimerId); + delayTimerId = null; if (!showing) return; showing = false; if (progressTimerId != null) { diff --git a/config/config.exs b/config/config.exs index 6f933ca..2ec69c5 100644 --- a/config/config.exs +++ b/config/config.exs @@ -13,9 +13,13 @@ config :something_erlang, # Configures the endpoint config :something_erlang, SomethingErlangWeb.Endpoint, url: [host: "localhost"], - render_errors: [view: SomethingErlangWeb.ErrorView, accepts: ~w(html json), layout: false], + adapter: Bandit.PhoenixAdapter, + render_errors: [ + formats: [html: SomethingErlangWeb.ErrorHTML, json: SomethingErlangWeb.ErrorJSON], + layout: false + ], pubsub_server: SomethingErlang.PubSub, - live_view: [signing_salt: "2Zh6iffO"] + live_view: [signing_salt: "00UFDP60"] # Configures the mailer # @@ -26,12 +30,9 @@ config :something_erlang, SomethingErlangWeb.Endpoint, # at the `config/runtime.exs`. config :something_erlang, SomethingErlang.Mailer, adapter: Swoosh.Adapters.Local -# Swoosh API client is needed for adapters other than SMTP. -config :swoosh, :api_client, false - # Configure esbuild (the version is required) config :esbuild, - version: "0.14.29", + version: "0.14.41", default: [ args: ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), @@ -39,14 +40,15 @@ config :esbuild, env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} ] +# Configure tailwind (the version is required) config :tailwind, - version: "3.0.24", + version: "3.2.4", default: [ args: ~w( - --config=tailwind.config.js - --input=css/app.css - --output=../priv/static/assets/app.css - ), + --config=tailwind.config.js + --input=css/app.css + --output=../priv/static/assets/app.css + ), cd: Path.expand("../assets", __DIR__) ] diff --git a/config/dev.exs b/config/dev.exs index 83bc97f..82bd406 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -5,7 +5,6 @@ config :something_erlang, SomethingErlang.Repo, username: "postgres", password: "postgres", hostname: "localhost", - port: 5432, database: "something_erlang_dev", stacktrace: true, show_sensitive_data_on_connection_error: true, @@ -20,13 +19,12 @@ config :something_erlang, SomethingErlang.Repo, config :something_erlang, SomethingErlangWeb.Endpoint, # Binding to loopback ipv4 address prevents access from other machines. # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. - http: [ip: {0, 0, 0, 0}, port: 4000], + http: [ip: {127, 0, 0, 1}, port: 4000], check_origin: false, code_reloader: true, debug_errors: true, - secret_key_base: "zbRbqQ0NBLDxPdlKgtVwPtnWMd/lp5G7aSanVWVVY95PwxK1LKkyyZqyLTtZdGWB", + secret_key_base: "uUSGthtWxUO9OOLlTaRq78iLoKgqFxonmAsZ5wmuLnnsKc1l3MVJkPDUNGu06+4Q", watchers: [ - # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} ] @@ -39,7 +37,6 @@ config :something_erlang, SomethingErlangWeb.Endpoint, # # mix phx.gen.cert # -# Note that this task requires Erlang/OTP 20 or later. # Run `mix help phx.gen.cert` for more information. # # The `http:` config above can be replaced with: @@ -61,11 +58,13 @@ config :something_erlang, SomethingErlangWeb.Endpoint, patterns: [ ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", ~r"priv/gettext/.*(po)$", - ~r"lib/something_erlang_web/(live|views)/.*(ex)$", - ~r"lib/something_erlang_web/templates/.*(eex)$" + ~r"lib/something_erlang_web/(controllers|live|components)/.*(ex|heex)$" ] ] +# Enable dev routes for dashboard and mailbox +config :something_erlang, dev_routes: true + # Do not include metadata nor timestamps in development logs config :logger, :console, format: "[$level] $message\n" @@ -75,3 +74,6 @@ config :phoenix, :stacktrace_depth, 20 # Initialize plugs at runtime for faster development compilation config :phoenix, :plug_init_mode, :runtime + +# Disable swoosh api client as it is only required for production adapters. +config :swoosh, :api_client, false diff --git a/config/prod.exs b/config/prod.exs index 229a92f..f1cf490 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -3,48 +3,19 @@ import Config # For production, don't forget to configure the url host # to something meaningful, Phoenix uses this information # when generating URLs. -# + # Note we also include the path to a cache manifest # containing the digested version of static files. This # manifest is generated by the `mix phx.digest` task, # which you should run after static files are built and # before starting your production server. -config :something_erlang, SomethingErlangWeb.Endpoint, - cache_static_manifest: "priv/static/cache_manifest.json" +config :something_erlang, SomethingErlangWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" + +# Configures Swoosh API Client +config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: SomethingErlang.Finch # Do not print debug messages in production config :logger, level: :info -# ## SSL Support -# -# To get SSL working, you will need to add the `https` key -# to the previous section and set your `:url` port to 443: -# -# config :something_erlang, SomethingErlangWeb.Endpoint, -# ..., -# url: [host: "example.com", port: 443], -# https: [ -# ..., -# port: 443, -# cipher_suite: :strong, -# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), -# certfile: System.get_env("SOME_APP_SSL_CERT_PATH") -# ] -# -# The `cipher_suite` is set to `:strong` to support only the -# latest and more secure SSL ciphers. This means old browsers -# and clients may not be supported. You can set it to -# `:compatible` for wider support. -# -# `:keyfile` and `:certfile` expect an absolute path to the key -# and cert in disk or a relative path inside priv, for example -# "priv/ssl/server.key". For all supported SSL configuration -# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 -# -# We also recommend setting `force_ssl` in your endpoint, ensuring -# no data is ever sent via http, always redirecting to https: -# -# config :something_erlang, SomethingErlangWeb.Endpoint, -# force_ssl: [hsts: true] -# -# Check `Plug.SSL` for all available options in `force_ssl`. +# Runtime production configuration, including reading +# of environment variables, is done on config/runtime.exs. diff --git a/config/runtime.exs b/config/runtime.exs index f94e9cd..ee1121b 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -63,6 +63,38 @@ if config_env() == :prod do ], secret_key_base: secret_key_base + # ## SSL Support + # + # To get SSL working, you will need to add the `https` key + # to your endpoint configuration: + # + # config :something_erlang, SomethingErlangWeb.Endpoint, + # https: [ + # ..., + # port: 443, + # cipher_suite: :strong, + # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), + # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") + # ] + # + # The `cipher_suite` is set to `:strong` to support only the + # latest and more secure SSL ciphers. This means old browsers + # and clients may not be supported. You can set it to + # `:compatible` for wider support. + # + # `:keyfile` and `:certfile` expect an absolute path to the key + # and cert in disk or a relative path inside priv, for example + # "priv/ssl/server.key". For all supported SSL configuration + # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 + # + # We also recommend setting `force_ssl` in your endpoint, ensuring + # no data is ever sent via http, always redirecting to https: + # + # config :something_erlang, SomethingErlangWeb.Endpoint, + # force_ssl: [hsts: true] + # + # Check `Plug.SSL` for all available options in `force_ssl`. + # ## Configuring the mailer # # In production you need to configure the mailer to use a different adapter. diff --git a/config/test.exs b/config/test.exs index a722965..12883df 100644 --- a/config/test.exs +++ b/config/test.exs @@ -20,14 +20,17 @@ config :something_erlang, SomethingErlang.Repo, # you can enable the server option below. config :something_erlang, SomethingErlangWeb.Endpoint, http: [ip: {127, 0, 0, 1}, port: 4002], - secret_key_base: "HtGnJwM5x3sH8vM0q0wZVOLL5vx0f12/P0Sfd96Hv/pNDvFdwTC8FhHuRDz0Ba6b", + secret_key_base: "hnSErwuszrqB3jBjmZVIAgb8D7m4nZPqti/6WDaL1pJi6l3/kQZY0Z4H4JAPadgF", server: false # In test we don't send emails. config :something_erlang, SomethingErlang.Mailer, adapter: Swoosh.Adapters.Test +# Disable swoosh api client as it is only required for production adapters. +config :swoosh, :api_client, false + # Print only warnings and errors during test -config :logger, level: :warn +config :logger, level: :warning # Initialize plugs at runtime for faster test compilation config :phoenix, :plug_init_mode, :runtime diff --git a/lib/something_erlang/accounts.ex b/lib/something_erlang/accounts.ex index 71a2c13..bb5ac18 100644 --- a/lib/something_erlang/accounts.ex +++ b/lib/something_erlang/accounts.ex @@ -90,21 +90,11 @@ defmodule SomethingErlang.Accounts do """ def change_user_registration(%User{} = user, attrs \\ %{}) do - User.registration_changeset(user, attrs, hash_password: false) + User.registration_changeset(user, attrs, hash_password: false, validate_email: false) end ## Settings - def change_user_sadata(%User{} = user, attrs \\ %{}) do - User.sadata_changeset(user, attrs) - end - - def update_sadata(%User{} = user, attrs \\ %{}) do - user - |> change_user_sadata(attrs) - |> Repo.update() - end - @doc """ Returns an `%Ecto.Changeset{}` for changing the user email. @@ -115,7 +105,7 @@ defmodule SomethingErlang.Accounts do """ def change_user_email(user, attrs \\ %{}) do - User.email_changeset(user, attrs) + User.email_changeset(user, attrs, validate_email: false) end @doc """ @@ -167,16 +157,16 @@ defmodule SomethingErlang.Accounts do |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context])) end - @doc """ + @doc ~S""" Delivers the update email instructions to the given user. ## Examples - iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1)) + iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm_email/#{&1})") {:ok, %{to: ..., body: ...}} """ - def deliver_update_email_instructions(%User{} = user, current_email, update_email_url_fun) + def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun) when is_function(update_email_url_fun, 1) do {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") @@ -247,22 +237,22 @@ defmodule SomethingErlang.Accounts do @doc """ Deletes the signed token with the given context. """ - def delete_session_token(token) do + def delete_user_session_token(token) do Repo.delete_all(UserToken.token_and_context_query(token, "session")) :ok end ## Confirmation - @doc """ + @doc ~S""" Delivers the confirmation email instructions to the given user. ## Examples - iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :edit, &1)) + iex> deliver_user_confirmation_instructions(user, &url(~p"/users/confirm/#{&1}")) {:ok, %{to: ..., body: ...}} - iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :edit, &1)) + iex> deliver_user_confirmation_instructions(confirmed_user, &url(~p"/users/confirm/#{&1}")) {:error, :already_confirmed} """ @@ -301,12 +291,12 @@ defmodule SomethingErlang.Accounts do ## Reset password - @doc """ + @doc ~S""" Delivers the reset password email to the given user. ## Examples - iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1)) + iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/#{&1}")) {:ok, %{to: ..., body: ...}} """ diff --git a/lib/something_erlang/accounts/user.ex b/lib/something_erlang/accounts/user.ex index 969b32b..608afbb 100644 --- a/lib/something_erlang/accounts/user.ex +++ b/lib/something_erlang/accounts/user.ex @@ -8,20 +8,9 @@ defmodule SomethingErlang.Accounts.User do field :hashed_password, :string, redact: true field :confirmed_at, :naive_datetime - field :bbuserid, :string - field :bbpassword, :string - timestamps() end - @doc """ - A user changeset for SA data. - """ - def sadata_changeset(user, attrs, _opts \\ []) do - user - |> cast(attrs, [:bbuserid, :bbpassword]) - end - @doc """ A user changeset for registration. @@ -38,21 +27,26 @@ defmodule SomethingErlang.Accounts.User do password field is not desired (like when using this changeset for validations on a LiveView form), this option can be set to `false`. Defaults to `true`. + + * `:validate_email` - Validates the uniqueness of the email, in case + you don't want to validate the uniqueness of the email (like when + using this changeset for validations on a LiveView form before + submitting the form), this option can be set to `false`. + Defaults to `true`. """ def registration_changeset(user, attrs, opts \\ []) do user |> cast(attrs, [:email, :password]) - |> validate_email() + |> validate_email(opts) |> validate_password(opts) end - defp validate_email(changeset) do + defp validate_email(changeset, opts) do changeset |> validate_required([:email]) |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") |> validate_length(:email, max: 160) - |> unsafe_validate_unique(:email, SomethingErlang.Repo) - |> unique_constraint(:email) + |> maybe_validate_unique_email(opts) end defp validate_password(changeset, opts) do @@ -80,15 +74,25 @@ defmodule SomethingErlang.Accounts.User do end end + defp maybe_validate_unique_email(changeset, opts) do + if Keyword.get(opts, :validate_email, true) do + changeset + |> unsafe_validate_unique(:email, SomethingErlang.Repo) + |> unique_constraint(:email) + else + changeset + end + end + @doc """ A user changeset for changing the email. It requires the email to change otherwise an error is added. """ - def email_changeset(user, attrs) do + def email_changeset(user, attrs, opts \\ []) do user |> cast(attrs, [:email]) - |> validate_email() + |> validate_email(opts) |> case do %{changes: %{email: _}} = changeset -> changeset %{} = changeset -> add_error(changeset, :email, "did not change") diff --git a/lib/something_erlang/application.ex b/lib/something_erlang/application.ex index ce50fc2..f8c5e09 100644 --- a/lib/something_erlang/application.ex +++ b/lib/something_erlang/application.ex @@ -8,14 +8,14 @@ defmodule SomethingErlang.Application do @impl true def start(_type, _args) do children = [ - {Registry, [name: SomethingErlang.Registry.Grovers, keys: :unique]}, - {DynamicSupervisor, [name: SomethingErlang.Supervisor.Grovers, strategy: :one_for_one]}, - # Start the Ecto repository - SomethingErlang.Repo, # Start the Telemetry supervisor SomethingErlangWeb.Telemetry, + # Start the Ecto repository + SomethingErlang.Repo, # Start the PubSub system {Phoenix.PubSub, name: SomethingErlang.PubSub}, + # Start Finch + {Finch, name: SomethingErlang.Finch}, # Start the Endpoint (http/https) SomethingErlangWeb.Endpoint # Start a worker by calling: SomethingErlang.Worker.start_link(arg) diff --git a/lib/something_erlang/forums.ex b/lib/something_erlang/forums.ex deleted file mode 100644 index 142620b..0000000 --- a/lib/something_erlang/forums.ex +++ /dev/null @@ -1,106 +0,0 @@ -defmodule SomethingErlang.Forums do - @moduledoc """ - The Forums context. - """ - - import Ecto.Query, warn: false - alias SomethingErlang.Repo - - alias SomethingErlang.Forums.Thread - - @doc """ - Returns the list of threads. - - ## Examples - - iex> list_threads() - [%Thread{}, ...] - - """ - def list_threads do - Repo.all(Thread) - end - - @doc """ - Gets a single thread. - - Raises `Ecto.NoResultsError` if the Thread does not exist. - - ## Examples - - iex> get_thread!(123) - %Thread{} - - iex> get_thread!(456) - ** (Ecto.NoResultsError) - - """ - def get_thread!(id), - # Repo.get!(Thread, id) - do: %Thread{id: id, thread_id: id, title: "foo"} - - @doc """ - Creates a thread. - - ## Examples - - iex> create_thread(%{field: value}) - {:ok, %Thread{}} - - iex> create_thread(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_thread(attrs \\ %{}) do - %Thread{} - |> Thread.changeset(attrs) - |> Repo.insert() - end - - @doc """ - Updates a thread. - - ## Examples - - iex> update_thread(thread, %{field: new_value}) - {:ok, %Thread{}} - - iex> update_thread(thread, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def update_thread(%Thread{} = thread, attrs) do - thread - |> Thread.changeset(attrs) - |> Repo.update() - end - - @doc """ - Deletes a thread. - - ## Examples - - iex> delete_thread(thread) - {:ok, %Thread{}} - - iex> delete_thread(thread) - {:error, %Ecto.Changeset{}} - - """ - def delete_thread(%Thread{} = thread) do - Repo.delete(thread) - end - - @doc """ - Returns an `%Ecto.Changeset{}` for tracking thread changes. - - ## Examples - - iex> change_thread(thread) - %Ecto.Changeset{data: %Thread{}} - - """ - def change_thread(%Thread{} = thread, attrs \\ %{}) do - Thread.changeset(thread, attrs) - end -end diff --git a/lib/something_erlang/forums/thread.ex b/lib/something_erlang/forums/thread.ex deleted file mode 100644 index 226a5aa..0000000 --- a/lib/something_erlang/forums/thread.ex +++ /dev/null @@ -1,18 +0,0 @@ -defmodule SomethingErlang.Forums.Thread do - use Ecto.Schema - import Ecto.Changeset - - schema "threads" do - field :thread_id, :integer - field :title, :string - - timestamps() - end - - @doc false - def changeset(thread, attrs) do - thread - |> cast(attrs, [:title, :thread_id]) - |> validate_required([:title, :thread_id]) - end -end diff --git a/lib/something_erlang_web.ex b/lib/something_erlang_web.ex index 4628108..e2c8620 100644 --- a/lib/something_erlang_web.ex +++ b/lib/something_erlang_web.ex @@ -1,76 +1,29 @@ defmodule SomethingErlangWeb do @moduledoc """ The entrypoint for defining your web interface, such - as controllers, views, channels and so on. + as controllers, components, channels, and so on. This can be used in your application as: use SomethingErlangWeb, :controller - use SomethingErlangWeb, :view + use SomethingErlangWeb, :html - The definitions below will be executed for every view, - controller, etc, so keep them short and clean, focused + The definitions below will be executed for every controller, + component, etc, so keep them short and clean, focused on imports, uses and aliases. Do NOT define functions inside the quoted expressions - below. Instead, define any helper function in modules - and import those modules here. + below. Instead, define additional modules and import + those modules here. """ - def controller do - quote do - use Phoenix.Controller, namespace: SomethingErlangWeb - - import Plug.Conn - import SomethingErlangWeb.Gettext - alias SomethingErlangWeb.Router.Helpers, as: Routes - end - end - - def view do - quote do - use Phoenix.View, - root: "lib/something_erlang_web/templates", - namespace: SomethingErlangWeb - - # Import convenience functions from controllers - import Phoenix.Controller, - only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] - - # Include shared imports and aliases for views - unquote(view_helpers()) - end - end - - def live_view do - quote do - use Phoenix.LiveView, - layout: {SomethingErlangWeb.LayoutView, "live.html"} - - unquote(view_helpers()) - end - end - - def live_component do - quote do - use Phoenix.LiveComponent - - unquote(view_helpers()) - end - end - - def component do - quote do - use Phoenix.Component - - unquote(view_helpers()) - end - end + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) def router do quote do - use Phoenix.Router + use Phoenix.Router, helpers: false + # Import common connection and controller functions to use in pipelines import Plug.Conn import Phoenix.Controller import Phoenix.LiveView.Router @@ -80,26 +33,74 @@ defmodule SomethingErlangWeb do def channel do quote do use Phoenix.Channel - import SomethingErlangWeb.Gettext end end - defp view_helpers do + def controller do quote do - # Use all HTML functionality (forms, tags, etc) - use Phoenix.HTML + use Phoenix.Controller, + formats: [:html, :json], + layouts: [html: SomethingErlangWeb.Layouts] - # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) - import Phoenix.LiveView.Helpers - import SomethingErlangWeb.LiveHelpers - - # Import basic rendering functionality (render, render_layout, etc) - import Phoenix.View - - import SomethingErlangWeb.ErrorHelpers + import Plug.Conn import SomethingErlangWeb.Gettext - alias SomethingErlangWeb.Router.Helpers, as: Routes - alias SomethingErlangWeb.Icons + + unquote(verified_routes()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, + layout: {SomethingErlangWeb.Layouts, :app} + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + # Import convenience functions from controllers + import Phoenix.Controller, + only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + # Include general helpers for rendering HTML + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + # HTML escaping functionality + import Phoenix.HTML + # Core UI components and translation + import SomethingErlangWeb.CoreComponents + import SomethingErlangWeb.Gettext + + # Shortcut for generating JS commands + alias Phoenix.LiveView.JS + + # Routes generation with the ~p sigil + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: SomethingErlangWeb.Endpoint, + router: SomethingErlangWeb.Router, + statics: SomethingErlangWeb.static_paths() end end diff --git a/lib/something_erlang_web/components/core_components.ex b/lib/something_erlang_web/components/core_components.ex new file mode 100644 index 0000000..0d26839 --- /dev/null +++ b/lib/something_erlang_web/components/core_components.ex @@ -0,0 +1,633 @@ +defmodule SomethingErlangWeb.CoreComponents do + @moduledoc """ + Provides core UI components. + + The components in this module use Tailwind CSS, a utility-first CSS framework. + See the [Tailwind CSS documentation](https://tailwindcss.com) to learn how to + customize the generated components in this module. + + Icons are provided by [heroicons](https://heroicons.com), using the + [heroicons_elixir](https://github.com/mveytsman/heroicons_elixir) project. + """ + use Phoenix.Component + + alias Phoenix.LiveView.JS + import SomethingErlangWeb.Gettext + + @doc """ + Renders a modal. + + ## Examples + + <.modal id="confirm-modal"> + Are you sure? + <:confirm>OK + <:cancel>Cancel + + + JS commands may be passed to the `:on_cancel` and `on_confirm` attributes + for the caller to react to each button press, for example: + + <.modal id="confirm" on_confirm={JS.push("delete")} on_cancel={JS.navigate(~p"/posts")}> + Are you sure you? + <:confirm>OK + <:cancel>Cancel + + """ + attr :id, :string, required: true + attr :show, :boolean, default: false + attr :on_cancel, JS, default: %JS{} + attr :on_confirm, JS, default: %JS{} + + slot :inner_block, required: true + slot :title + slot :subtitle + slot :confirm + slot :cancel + + def modal(assigns) do + ~H""" + """ end @@ -80,22 +63,49 @@ defmodule SomethingErlangWeb.ThreadLive.Show do ~H""" """ end + + def mount(_params, _session, socket) do + {:ok, socket} + end + + def handle_params(%{"id" => id, "page" => page}, _, socket) do + thread = Grover.get_thread!(id, page |> String.to_integer()) + + {:noreply, + socket + |> assign(:page_title, thread.title) + |> assign(:thread, thread)} + end + + def handle_params(%{"id" => id}, _, socket) do + params = %{page: 1} + {:noreply, push_redirect(socket, to: ~p"/thread/#{id}?#{params}")} + end + + def handle_params(%{}, _, socket) do + {:noreply, socket} + end end diff --git a/lib/something_erlang_web/live/thread_live/form_component.ex b/lib/something_erlang_web/live/thread_live/form_component.ex deleted file mode 100644 index fc9004e..0000000 --- a/lib/something_erlang_web/live/thread_live/form_component.ex +++ /dev/null @@ -1,55 +0,0 @@ -defmodule SomethingErlangWeb.ThreadLive.FormComponent do - use SomethingErlangWeb, :live_component - - alias SomethingErlang.Forums - - @impl true - def update(%{thread: thread} = assigns, socket) do - changeset = Forums.change_thread(thread) - - {:ok, - socket - |> assign(assigns) - |> assign(:changeset, changeset)} - end - - @impl true - def handle_event("validate", %{"thread" => thread_params}, socket) do - changeset = - socket.assigns.thread - |> Forums.change_thread(thread_params) - |> Map.put(:action, :validate) - - {:noreply, assign(socket, :changeset, changeset)} - end - - def handle_event("save", %{"thread" => thread_params}, socket) do - save_thread(socket, socket.assigns.action, thread_params) - end - - defp save_thread(socket, :edit, thread_params) do - case Forums.update_thread(socket.assigns.thread, thread_params) do - {:ok, _thread} -> - {:noreply, - socket - |> put_flash(:info, "Thread updated successfully") - |> push_redirect(to: socket.assigns.return_to)} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, :changeset, changeset)} - end - end - - defp save_thread(socket, :new, thread_params) do - case Forums.create_thread(thread_params) do - {:ok, _thread} -> - {:noreply, - socket - |> put_flash(:info, "Thread created successfully") - |> push_redirect(to: socket.assigns.return_to)} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, changeset: changeset)} - end - end -end diff --git a/lib/something_erlang_web/live/thread_live/form_component.html.heex b/lib/something_erlang_web/live/thread_live/form_component.html.heex deleted file mode 100644 index ec10417..0000000 --- a/lib/something_erlang_web/live/thread_live/form_component.html.heex +++ /dev/null @@ -1,24 +0,0 @@ -
-

<%= @title %>

- - <.form - let={f} - for={@changeset} - id="thread-form" - phx-target={@myself} - phx-change="validate" - phx-submit="save"> - - <%= label f, :title %> - <%= text_input f, :title %> - <%= error_tag f, :title %> - - <%= label f, :thread_id %> - <%= number_input f, :thread_id %> - <%= error_tag f, :thread_id %> - -
- <%= submit "Save", phx_disable_with: "Saving..." %> -
- -
diff --git a/lib/something_erlang_web/live/thread_live/index.ex b/lib/something_erlang_web/live/thread_live/index.ex deleted file mode 100644 index 42f1980..0000000 --- a/lib/something_erlang_web/live/thread_live/index.ex +++ /dev/null @@ -1,46 +0,0 @@ -defmodule SomethingErlangWeb.ThreadLive.Index do - use SomethingErlangWeb, :live_view - - alias SomethingErlang.Forums - alias SomethingErlang.Forums.Thread - - @impl true - def mount(_params, _session, socket) do - {:ok, assign(socket, :threads, list_threads())} - end - - @impl true - def handle_params(params, _url, socket) do - {:noreply, apply_action(socket, socket.assigns.live_action, params)} - end - - defp apply_action(socket, :edit, %{"id" => id}) do - socket - |> assign(:page_title, "Edit Thread") - |> assign(:thread, Forums.get_thread!(id)) - end - - defp apply_action(socket, :new, _params) do - socket - |> assign(:page_title, "New Thread") - |> assign(:thread, %Thread{}) - end - - defp apply_action(socket, :index, _params) do - socket - |> assign(:page_title, "Listing Threads") - |> assign(:thread, nil) - end - - @impl true - def handle_event("delete", %{"id" => id}, socket) do - thread = Forums.get_thread!(id) - {:ok, _} = Forums.delete_thread(thread) - - {:noreply, assign(socket, :threads, list_threads())} - end - - defp list_threads do - Forums.list_threads() - end -end diff --git a/lib/something_erlang_web/live/thread_live/index.html.heex b/lib/something_erlang_web/live/thread_live/index.html.heex deleted file mode 100644 index 37a49f7..0000000 --- a/lib/something_erlang_web/live/thread_live/index.html.heex +++ /dev/null @@ -1,41 +0,0 @@ -

Listing Threads

- -<%= if @live_action in [:new, :edit] do %> - <.modal return_to={Routes.thread_index_path(@socket, :index)}> - <.live_component - module={SomethingErlangWeb.ThreadLive.FormComponent} - id={@thread.id || :new} - title={@page_title} - action={@live_action} - thread={@thread} - return_to={Routes.thread_index_path(@socket, :index)} - /> - -<% end %> - - - - - - - - - - - - <%= for thread <- @threads do %> - - - - - - - <% end %> - -
TitleThread
<%= thread.title %><%= thread.thread_id %> - <%= live_redirect "Show", to: Routes.thread_show_path(@socket, :show, thread) %> - <%= live_patch "Edit", to: Routes.thread_index_path(@socket, :edit, thread) %> - <%= link "Delete", to: "#", phx_click: "delete", phx_value_id: thread.id, data: [confirm: "Are you sure?"] %> -
- -<%= live_patch "New Thread", to: Routes.thread_index_path(@socket, :new) %> diff --git a/lib/something_erlang_web/live/thread_live/show.html.heex b/lib/something_erlang_web/live/thread_live/show.html.heex deleted file mode 100644 index 96330ae..0000000 --- a/lib/something_erlang_web/live/thread_live/show.html.heex +++ /dev/null @@ -1,26 +0,0 @@ -<%= if @live_action in [:edit] do %> - <.modal return_to={Routes.thread_show_path(@socket, :show, @thread)}> - <.live_component - module={SomethingErlangWeb.ThreadLive.FormComponent} - id={@thread.id} - title={@page_title} - action={@live_action} - thread={@thread} - return_to={Routes.thread_show_path(@socket, :show, @thread)} - /> - -<% end %> - -

- <%= raw @thread.title %> -

- -
- <.pagination socket={@socket} thread={@thread} /> - - <%= for post <- @thread.posts do %> - <.post author={post.userinfo} article={post.postbody} date={post.postdate} /> - <% end %> - - <.pagination socket={@socket} thread={@thread} /> -
diff --git a/lib/something_erlang_web/live/user_confirmation_instructions_live.ex b/lib/something_erlang_web/live/user_confirmation_instructions_live.ex new file mode 100644 index 0000000..e2b3bf4 --- /dev/null +++ b/lib/something_erlang_web/live/user_confirmation_instructions_live.ex @@ -0,0 +1,45 @@ +defmodule SomethingErlangWeb.UserConfirmationInstructionsLive do + use SomethingErlangWeb, :live_view + + alias SomethingErlang.Accounts + + def render(assigns) do + ~H""" + <.header>Resend confirmation instructions + + <.simple_form :let={f} for={:user} id="resend_confirmation_form" phx-submit="send_instructions"> + <.input field={{f, :email}} type="email" label="Email" required /> + <:actions> + <.button phx-disable-with="Sending...">Resend confirmation instructions + + + +

+ <.link href={~p"/users/register"}>Register + | + <.link href={~p"/users/log_in"}>Log in +

+ """ + end + + def mount(_params, _session, socket) do + {:ok, socket} + end + + def handle_event("send_instructions", %{"user" => %{"email" => email}}, socket) do + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_user_confirmation_instructions( + user, + &url(~p"/users/confirm/#{&1}") + ) + end + + info = + "If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly." + + {:noreply, + socket + |> put_flash(:info, info) + |> redirect(to: ~p"/")} + end +end diff --git a/lib/something_erlang_web/live/user_confirmation_live.ex b/lib/something_erlang_web/live/user_confirmation_live.ex new file mode 100644 index 0000000..48aad80 --- /dev/null +++ b/lib/something_erlang_web/live/user_confirmation_live.ex @@ -0,0 +1,58 @@ +defmodule SomethingErlangWeb.UserConfirmationLive do + use SomethingErlangWeb, :live_view + + alias SomethingErlang.Accounts + + def render(%{live_action: :edit} = assigns) do + ~H""" +
+ <.header class="text-center">Confirm Account + + <.simple_form :let={f} for={:user} id="confirmation_form" phx-submit="confirm_account"> + <.input field={{f, :token}} type="hidden" value={@token} /> + <:actions> + <.button phx-disable-with="Confirming..." class="w-full">Confirm my account + + + +

+ <.link href={~p"/users/register"}>Register + | + <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(params, _session, socket) do + {:ok, assign(socket, token: params["token"]), temporary_assigns: [token: nil]} + end + + # Do not log in the user after confirmation to avoid a + # leaked token giving the user access to the account. + def handle_event("confirm_account", %{"user" => %{"token" => token}}, socket) do + case Accounts.confirm_user(token) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:info, "User confirmed successfully.") + |> redirect(to: ~p"/")} + + :error -> + # If there is a current user and the account was already confirmed, + # then odds are that the confirmation link was already visited, either + # by some automation or by the user themselves, so we redirect without + # a warning message. + case socket.assigns do + %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> + {:noreply, redirect(socket, to: ~p"/")} + + %{} -> + {:noreply, + socket + |> put_flash(:error, "User confirmation link is invalid or it has expired.") + |> redirect(to: ~p"/")} + end + end + end +end diff --git a/lib/something_erlang_web/live/user_forgot_password_live.ex b/lib/something_erlang_web/live/user_forgot_password_live.ex new file mode 100644 index 0000000..0bcda92 --- /dev/null +++ b/lib/something_erlang_web/live/user_forgot_password_live.ex @@ -0,0 +1,51 @@ +defmodule SomethingErlangWeb.UserForgotPasswordLive do + use SomethingErlangWeb, :live_view + + alias SomethingErlang.Accounts + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + Forgot your password? + <:subtitle>We'll send a password reset link to your inbox + + + <.simple_form :let={f} id="reset_password_form" for={:user} phx-submit="send_email"> + <.input field={{f, :email}} type="email" placeholder="Email" required /> + <:actions> + <.button phx-disable-with="Sending..." class="w-full"> + Send password reset instructions + + + +

+ <.link href={~p"/users/register"}>Register + | + <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(_params, _session, socket) do + {:ok, socket} + end + + def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_user_reset_password_instructions( + user, + &url(~p"/users/reset_password/#{&1}") + ) + end + + info = + "If your email is in our system, you will receive instructions to reset your password shortly." + + {:noreply, + socket + |> put_flash(:info, info) + |> redirect(to: ~p"/")} + end +end diff --git a/lib/something_erlang_web/live/user_live_auth.ex b/lib/something_erlang_web/live/user_live_auth.ex deleted file mode 100644 index 2441600..0000000 --- a/lib/something_erlang_web/live/user_live_auth.ex +++ /dev/null @@ -1,16 +0,0 @@ -defmodule SomethingErlangWeb.UserLiveAuth do - import Phoenix.LiveView - - alias SomethingErlang.Accounts - - def on_mount(:default, _params, %{"user_token" => user_token} = _session, socket) do - user = Accounts.get_user_by_session_token(user_token) - socket = assign_new(socket, :current_user, fn -> user end) - - if socket.assigns.current_user.confirmed_at do - {:cont, socket} - else - {:halt, redirect(socket, to: "/login")} - end - end -end diff --git a/lib/something_erlang_web/live/user_login_live.ex b/lib/something_erlang_web/live/user_login_live.ex new file mode 100644 index 0000000..74e2d41 --- /dev/null +++ b/lib/something_erlang_web/live/user_login_live.ex @@ -0,0 +1,49 @@ +defmodule SomethingErlangWeb.UserLoginLive do + use SomethingErlangWeb, :live_view + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + Sign in to account + <:subtitle> + Don't have an account? + <.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline"> + Sign up + + for an account now. + + + + <.simple_form + :let={f} + id="login_form" + for={:user} + action={~p"/users/log_in"} + as={:user} + phx-update="ignore" + > + <.input field={{f, :email}} type="email" label="Email" required /> + <.input field={{f, :password}} type="password" label="Password" required /> + + <:actions :let={f}> + <.input field={{f, :remember_me}} type="checkbox" label="Keep me logged in" /> + <.link href={~p"/users/reset_password"} class="text-sm font-semibold"> + Forgot your password? + + + <:actions> + <.button phx-disable-with="Signing in..." class="w-full"> + Sign in + + + +
+ """ + end + + def mount(_params, _session, socket) do + email = live_flash(socket.assigns.flash, :email) + {:ok, assign(socket, email: email), temporary_assigns: [email: nil]} + end +end diff --git a/lib/something_erlang_web/live/user_registration_live.ex b/lib/something_erlang_web/live/user_registration_live.ex new file mode 100644 index 0000000..b2ee5c9 --- /dev/null +++ b/lib/something_erlang_web/live/user_registration_live.ex @@ -0,0 +1,74 @@ +defmodule SomethingErlangWeb.UserRegistrationLive do + use SomethingErlangWeb, :live_view + + alias SomethingErlang.Accounts + alias SomethingErlang.Accounts.User + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + Register for an account + <:subtitle> + Already registered? + <.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline"> + Sign in + + to your account now. + + + + <.simple_form + :let={f} + id="registration_form" + for={@changeset} + phx-submit="save" + phx-change="validate" + phx-trigger-action={@trigger_submit} + action={~p"/users/log_in?_action=registered"} + method="post" + as={:user} + > + <.error :if={@changeset.action == :insert}> + Oops, something went wrong! Please check the errors below. + + + <.input field={{f, :email}} type="email" label="Email" required /> + <.input field={{f, :password}} type="password" label="Password" required /> + + <:actions> + <.button phx-disable-with="Creating account..." class="w-full">Create an account + + +
+ """ + end + + def mount(_params, _session, socket) do + changeset = Accounts.change_user_registration(%User{}) + socket = assign(socket, changeset: changeset, trigger_submit: false) + {:ok, socket, temporary_assigns: [changeset: nil]} + end + + def handle_event("save", %{"user" => user_params}, socket) do + case Accounts.register_user(user_params) do + {:ok, user} -> + {:ok, _} = + Accounts.deliver_user_confirmation_instructions( + user, + &url(~p"/users/confirm/#{&1}") + ) + + changeset = Accounts.change_user_registration(user) + {:noreply, assign(socket, trigger_submit: true, changeset: changeset)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, :changeset, changeset)} + end + end + + def handle_event("validate", %{"user" => user_params}, socket) do + changeset = Accounts.change_user_registration(%User{}, user_params) + {:noreply, assign(socket, changeset: Map.put(changeset, :action, :validate))} + end +end diff --git a/lib/something_erlang_web/live/user_reset_password_live.ex b/lib/something_erlang_web/live/user_reset_password_live.ex new file mode 100644 index 0000000..5ee91c2 --- /dev/null +++ b/lib/something_erlang_web/live/user_reset_password_live.ex @@ -0,0 +1,87 @@ +defmodule SomethingErlangWeb.UserResetPasswordLive do + use SomethingErlangWeb, :live_view + + alias SomethingErlang.Accounts + + def render(assigns) do + ~H""" +
+ <.header class="text-center">Reset Password + + <.simple_form + :let={f} + for={@changeset} + id="reset_password_form" + phx-submit="reset_password" + phx-change="validate" + > + <.error :if={@changeset.action == :insert}> + Oops, something went wrong! Please check the errors below. + + + <.input field={{f, :password}} type="password" label="New password" required /> + <.input + field={{f, :password_confirmation}} + type="password" + label="Confirm new password" + required + /> + <:actions> + <.button phx-disable-with="Resetting..." class="w-full">Reset Password + + + +

+ <.link href={~p"/users/register"}>Register + | + <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(params, _session, socket) do + socket = assign_user_and_token(socket, params) + + socket = + case socket.assigns do + %{user: user} -> + assign(socket, :changeset, Accounts.change_user_password(user)) + + _ -> + socket + end + + {:ok, socket, temporary_assigns: [changeset: nil]} + end + + # Do not log in the user after reset password to avoid a + # leaked token giving the user access to the account. + def handle_event("reset_password", %{"user" => user_params}, socket) do + case Accounts.reset_user_password(socket.assigns.user, user_params) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:info, "Password reset successfully.") + |> redirect(to: ~p"/users/log_in")} + + {:error, changeset} -> + {:noreply, assign(socket, :changeset, Map.put(changeset, :action, :insert))} + end + end + + def handle_event("validate", %{"user" => user_params}, socket) do + changeset = Accounts.change_user_password(socket.assigns.user, user_params) + {:noreply, assign(socket, changeset: Map.put(changeset, :action, :validate))} + end + + defp assign_user_and_token(socket, %{"token" => token}) do + if user = Accounts.get_user_by_reset_password_token(token) do + assign(socket, user: user, token: token) + else + socket + |> put_flash(:error, "Reset password link is invalid or it has expired.") + |> redirect(to: ~p"/") + end + end +end diff --git a/lib/something_erlang_web/live/user_settings_live.ex b/lib/something_erlang_web/live/user_settings_live.ex new file mode 100644 index 0000000..a515b72 --- /dev/null +++ b/lib/something_erlang_web/live/user_settings_live.ex @@ -0,0 +1,161 @@ +defmodule SomethingErlangWeb.UserSettingsLive do + use SomethingErlangWeb, :live_view + + alias SomethingErlang.Accounts + + def render(assigns) do + ~H""" + <.header>Change Email + + <.simple_form + :let={f} + id="email_form" + for={@email_changeset} + phx-submit="update_email" + phx-change="validate_email" + > + <.error :if={@email_changeset.action == :insert}> + Oops, something went wrong! Please check the errors below. + + + <.input field={{f, :email}} type="email" label="Email" required /> + + <.input + field={{f, :current_password}} + name="current_password" + id="current_password_for_email" + type="password" + label="Current password" + value={@email_form_current_password} + required + /> + <:actions> + <.button phx-disable-with="Changing...">Change Email + + + + <.header>Change Password + + <.simple_form + :let={f} + id="password_form" + for={@password_changeset} + action={~p"/users/log_in?_action=password_updated"} + method="post" + phx-change="validate_password" + phx-submit="update_password" + phx-trigger-action={@trigger_submit} + > + <.error :if={@password_changeset.action == :insert}> + Oops, something went wrong! Please check the errors below. + + + <.input field={{f, :email}} type="hidden" value={@current_email} /> + + <.input field={{f, :password}} type="password" label="New password" required /> + <.input field={{f, :password_confirmation}} type="password" label="Confirm new password" /> + <.input + field={{f, :current_password}} + name="current_password" + type="password" + label="Current password" + id="current_password_for_password" + value={@current_password} + required + /> + <:actions> + <.button phx-disable-with="Changing...">Change Password + + + """ + end + + def mount(%{"token" => token}, _session, socket) do + socket = + case Accounts.update_user_email(socket.assigns.current_user, token) do + :ok -> + put_flash(socket, :info, "Email changed successfully.") + + :error -> + put_flash(socket, :error, "Email change link is invalid or it has expired.") + end + + {:ok, push_navigate(socket, to: ~p"/users/settings")} + end + + def mount(_params, _session, socket) do + user = socket.assigns.current_user + + socket = + socket + |> assign(:current_password, nil) + |> assign(:email_form_current_password, nil) + |> assign(:current_email, user.email) + |> assign(:email_changeset, Accounts.change_user_email(user)) + |> assign(:password_changeset, Accounts.change_user_password(user)) + |> assign(:trigger_submit, false) + + {:ok, socket} + end + + def handle_event("validate_email", params, socket) do + %{"current_password" => password, "user" => user_params} = params + email_changeset = Accounts.change_user_email(socket.assigns.current_user, user_params) + + socket = + assign(socket, + email_changeset: Map.put(email_changeset, :action, :validate), + email_form_current_password: password + ) + + {:noreply, socket} + end + + def handle_event("update_email", params, socket) do + %{"current_password" => password, "user" => user_params} = params + user = socket.assigns.current_user + + case Accounts.apply_user_email(user, password, user_params) do + {:ok, applied_user} -> + Accounts.deliver_user_update_email_instructions( + applied_user, + user.email, + &url(~p"/users/settings/confirm_email/#{&1}") + ) + + info = "A link to confirm your email change has been sent to the new address." + {:noreply, put_flash(socket, :info, info)} + + {:error, changeset} -> + {:noreply, assign(socket, :email_changeset, Map.put(changeset, :action, :insert))} + end + end + + def handle_event("validate_password", params, socket) do + %{"current_password" => password, "user" => user_params} = params + password_changeset = Accounts.change_user_password(socket.assigns.current_user, user_params) + + {:noreply, + socket + |> assign(:password_changeset, Map.put(password_changeset, :action, :validate)) + |> assign(:current_password, password)} + end + + def handle_event("update_password", params, socket) do + %{"current_password" => password, "user" => user_params} = params + user = socket.assigns.current_user + + case Accounts.update_user_password(user, password, user_params) do + {:ok, user} -> + socket = + socket + |> assign(:trigger_submit, true) + |> assign(:password_changeset, Accounts.change_user_password(user, user_params)) + + {:noreply, socket} + + {:error, changeset} -> + {:noreply, assign(socket, :password_changeset, changeset)} + end + end +end diff --git a/lib/something_erlang_web/router.ex b/lib/something_erlang_web/router.ex index 9adf4df..191e27d 100644 --- a/lib/something_erlang_web/router.ex +++ b/lib/something_erlang_web/router.ex @@ -7,7 +7,7 @@ defmodule SomethingErlangWeb.Router do plug :accepts, ["html"] plug :fetch_session plug :fetch_live_flash - plug :put_root_layout, {SomethingErlangWeb.LayoutView, :root} + plug :put_root_layout, {SomethingErlangWeb.Layouts, :root} plug :protect_from_forgery plug :put_secure_browser_headers plug :fetch_current_user @@ -18,32 +18,14 @@ defmodule SomethingErlangWeb.Router do end scope "/", SomethingErlangWeb do - pipe_through :browser + pipe_through [:browser] - get "/", PageController, :index - post "/", PageController, :to_forum_path - end + get "/", PageController, :home - scope "/thread", SomethingErlangWeb do - pipe_through :browser - - live "/:id", ThreadLive.Show, :show - end - - scope "/bookmarks", SomethingErlangWeb do - pipe_through :browser - - live "/", BookmarksLive.Show, :show - end - - scope "/admin", SomethingErlangWeb do - pipe_through [:browser, :require_authenticated_user] - - live "/thread", ThreadLive.Index, :index - live "/thread/new", ThreadLive.Index, :new - live "/thread/:id/edit", ThreadLive.Index, :edit - - live "/thread/:id/show/edit", ThreadLive.Show, :edit + live_session :user_browsing, + on_mount: [{SomethingErlangWeb.UserAuth, :mount_current_user}] do + live "/thread", ThreadLive + end end # Other scopes may use custom stacks. @@ -51,31 +33,19 @@ defmodule SomethingErlangWeb.Router do # pipe_through :api # end - # Enables LiveDashboard only for development - # - # If you want to use the LiveDashboard in production, you should put - # it behind authentication and allow only admins to access it. - # If your application does not have an admins-only section yet, - # you can use Plug.BasicAuth to set up some basic authentication - # as long as you are also using SSL (which you should anyway). - if Mix.env() in [:dev, :test] do + # Enable LiveDashboard and Swoosh mailbox preview in development + if Application.compile_env(:something_erlang, :dev_routes) do + # If you want to use the LiveDashboard in production, you should put + # it behind authentication and allow only admins to access it. + # If your application does not have an admins-only section yet, + # you can use Plug.BasicAuth to set up some basic authentication + # as long as you are also using SSL (which you should anyway). import Phoenix.LiveDashboard.Router - scope "/" do - pipe_through :browser - - live_dashboard "/dashboard", metrics: SomethingErlangWeb.Telemetry - end - end - - # Enables the Swoosh mailbox preview in development. - # - # Note that preview only shows emails that were sent by the same - # node running the Phoenix server. - if Mix.env() == :dev do scope "/dev" do pipe_through :browser + live_dashboard "/dashboard", metrics: SomethingErlangWeb.Telemetry forward "/mailbox", Plug.Swoosh.MailboxPreview end end @@ -85,31 +55,36 @@ defmodule SomethingErlangWeb.Router do scope "/", SomethingErlangWeb do pipe_through [:browser, :redirect_if_user_is_authenticated] - get "/users/register", UserRegistrationController, :new - post "/users/register", UserRegistrationController, :create - get "/users/log_in", UserSessionController, :new + live_session :redirect_if_user_is_authenticated, + on_mount: [{SomethingErlangWeb.UserAuth, :redirect_if_user_is_authenticated}] do + live "/users/register", UserRegistrationLive, :new + live "/users/log_in", UserLoginLive, :new + live "/users/reset_password", UserForgotPasswordLive, :new + live "/users/reset_password/:token", UserResetPasswordLive, :edit + end + post "/users/log_in", UserSessionController, :create - get "/users/reset_password", UserResetPasswordController, :new - post "/users/reset_password", UserResetPasswordController, :create - get "/users/reset_password/:token", UserResetPasswordController, :edit - put "/users/reset_password/:token", UserResetPasswordController, :update end scope "/", SomethingErlangWeb do pipe_through [:browser, :require_authenticated_user] - get "/users/settings", UserSettingsController, :edit - put "/users/settings", UserSettingsController, :update - get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email + live_session :require_authenticated_user, + on_mount: [{SomethingErlangWeb.UserAuth, :ensure_authenticated}] do + live "/users/settings", UserSettingsLive, :edit + live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email + end end scope "/", SomethingErlangWeb do pipe_through [:browser] delete "/users/log_out", UserSessionController, :delete - get "/users/confirm", UserConfirmationController, :new - post "/users/confirm", UserConfirmationController, :create - get "/users/confirm/:token", UserConfirmationController, :edit - post "/users/confirm/:token", UserConfirmationController, :update + + live_session :current_user, + on_mount: [{SomethingErlangWeb.UserAuth, :mount_current_user}] do + live "/users/confirm/:token", UserConfirmationLive, :edit + live "/users/confirm", UserConfirmationInstructionsLive, :new + end end end diff --git a/lib/something_erlang_web/telemetry.ex b/lib/something_erlang_web/telemetry.ex index 219d9d1..ba506cb 100644 --- a/lib/something_erlang_web/telemetry.ex +++ b/lib/something_erlang_web/telemetry.ex @@ -22,13 +22,34 @@ defmodule SomethingErlangWeb.Telemetry do def metrics do [ # Phoenix Metrics + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), summary("phoenix.endpoint.stop.duration", unit: {:native, :millisecond} ), + summary("phoenix.router_dispatch.start.system_time", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.exception.duration", + tags: [:route], + unit: {:native, :millisecond} + ), summary("phoenix.router_dispatch.stop.duration", tags: [:route], unit: {:native, :millisecond} ), + summary("phoenix.socket_connected.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_join.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), # Database Metrics summary("something_erlang.repo.query.total_time", diff --git a/lib/something_erlang_web/templates/layout/_user_menu.html.heex b/lib/something_erlang_web/templates/layout/_user_menu.html.heex deleted file mode 100644 index d2bafd7..0000000 --- a/lib/something_erlang_web/templates/layout/_user_menu.html.heex +++ /dev/null @@ -1,17 +0,0 @@ -
- <%= if @current_user do %> -

<%= @current_user.email %>

-
- <%= button class: "btn btn-square btn-outline btn-sm", to: Routes.user_settings_path(@conn, :edit), method: :get do %> - - <% end %> -
- <%= button "Log out", class: "btn btn-outline btn-sm", - to: Routes.user_session_path(@conn, :delete), method: :delete %> - <% else %> - <%= link "Register", class: "link", - to: Routes.user_registration_path(@conn, :new) %> - <%= button "Log in", class: "btn btn-sm", - to: Routes.user_session_path(@conn, :new), method: :get %> - <% end %> -
diff --git a/lib/something_erlang_web/templates/layout/app.html.heex b/lib/something_erlang_web/templates/layout/app.html.heex deleted file mode 100644 index c06efe9..0000000 --- a/lib/something_erlang_web/templates/layout/app.html.heex +++ /dev/null @@ -1,5 +0,0 @@ -
- - - <%= @inner_content %> -
diff --git a/lib/something_erlang_web/templates/layout/live.html.heex b/lib/something_erlang_web/templates/layout/live.html.heex deleted file mode 100644 index a9017f7..0000000 --- a/lib/something_erlang_web/templates/layout/live.html.heex +++ /dev/null @@ -1,11 +0,0 @@ -
- - - - - <%= @inner_content %> -
diff --git a/lib/something_erlang_web/templates/layout/root.html.heex b/lib/something_erlang_web/templates/layout/root.html.heex deleted file mode 100644 index b2a28d7..0000000 --- a/lib/something_erlang_web/templates/layout/root.html.heex +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - <%= live_title_tag assigns[:page_title] || "This awesome page", - suffix: " · Something Erlang" %> - - - - -
- -
- <%= @inner_content %> - - - diff --git a/lib/something_erlang_web/templates/page/index.html.heex b/lib/something_erlang_web/templates/page/index.html.heex deleted file mode 100644 index 75ad6fc..0000000 --- a/lib/something_erlang_web/templates/page/index.html.heex +++ /dev/null @@ -1,5 +0,0 @@ -<%= form_for @conn, - Routes.page_path(@conn, :to_forum_path), [as: :to], fn f -> %> - Something Awful URL: <%= url_input f, :forum_path %> - <%= submit "Redirect", class: "btn btn-sm" %> -<% end %> diff --git a/lib/something_erlang_web/templates/user_confirmation/edit.html.heex b/lib/something_erlang_web/templates/user_confirmation/edit.html.heex deleted file mode 100644 index e9bf443..0000000 --- a/lib/something_erlang_web/templates/user_confirmation/edit.html.heex +++ /dev/null @@ -1,12 +0,0 @@ -

Confirm account

- -<.form let={_f} for={:user} action={Routes.user_confirmation_path(@conn, :update, @token)}> -
- <%= submit "Confirm my account" %> -
- - -

- <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | - <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> -

diff --git a/lib/something_erlang_web/templates/user_confirmation/new.html.heex b/lib/something_erlang_web/templates/user_confirmation/new.html.heex deleted file mode 100644 index 4d9bee3..0000000 --- a/lib/something_erlang_web/templates/user_confirmation/new.html.heex +++ /dev/null @@ -1,15 +0,0 @@ -

Resend confirmation instructions

- -<.form let={f} for={:user} action={Routes.user_confirmation_path(@conn, :create)}> - <%= label f, :email %> - <%= email_input f, :email, required: true %> - -
- <%= submit "Resend confirmation instructions" %> -
- - -

- <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | - <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> -

diff --git a/lib/something_erlang_web/templates/user_registration/new.html.heex b/lib/something_erlang_web/templates/user_registration/new.html.heex deleted file mode 100644 index fac2f16..0000000 --- a/lib/something_erlang_web/templates/user_registration/new.html.heex +++ /dev/null @@ -1,26 +0,0 @@ -

Register

- -<.form let={f} for={@changeset} action={Routes.user_registration_path(@conn, :create)}> - <%= if @changeset.action do %> -
-

Oops, something went wrong! Please check the errors below.

-
- <% end %> - - <%= label f, :email %> - <%= email_input f, :email, required: true %> - <%= error_tag f, :email %> - - <%= label f, :password %> - <%= password_input f, :password, required: true %> - <%= error_tag f, :password %> - -
- <%= submit "Register" %> -
- - -

- <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> | - <%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %> -

diff --git a/lib/something_erlang_web/templates/user_reset_password/edit.html.heex b/lib/something_erlang_web/templates/user_reset_password/edit.html.heex deleted file mode 100644 index d8efb4b..0000000 --- a/lib/something_erlang_web/templates/user_reset_password/edit.html.heex +++ /dev/null @@ -1,26 +0,0 @@ -

Reset password

- -<.form let={f} for={@changeset} action={Routes.user_reset_password_path(@conn, :update, @token)}> - <%= if @changeset.action do %> -
-

Oops, something went wrong! Please check the errors below.

-
- <% end %> - - <%= label f, :password, "New password" %> - <%= password_input f, :password, required: true %> - <%= error_tag f, :password %> - - <%= label f, :password_confirmation, "Confirm new password" %> - <%= password_input f, :password_confirmation, required: true %> - <%= error_tag f, :password_confirmation %> - -
- <%= submit "Reset password" %> -
- - -

- <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | - <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> -

diff --git a/lib/something_erlang_web/templates/user_reset_password/new.html.heex b/lib/something_erlang_web/templates/user_reset_password/new.html.heex deleted file mode 100644 index 126cdba..0000000 --- a/lib/something_erlang_web/templates/user_reset_password/new.html.heex +++ /dev/null @@ -1,15 +0,0 @@ -

Forgot your password?

- -<.form let={f} for={:user} action={Routes.user_reset_password_path(@conn, :create)}> - <%= label f, :email %> - <%= email_input f, :email, required: true %> - -
- <%= submit "Send instructions to reset password" %> -
- - -

- <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | - <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> -

diff --git a/lib/something_erlang_web/templates/user_session/new.html.heex b/lib/something_erlang_web/templates/user_session/new.html.heex deleted file mode 100644 index 49a7d79..0000000 --- a/lib/something_erlang_web/templates/user_session/new.html.heex +++ /dev/null @@ -1,27 +0,0 @@ -

Log in

- -<.form let={f} for={@conn} action={Routes.user_session_path(@conn, :create)} as={:user}> - <%= if @error_message do %> -
-

<%= @error_message %>

-
- <% end %> - - <%= label f, :email %> - <%= email_input f, :email, required: true %> - - <%= label f, :password %> - <%= password_input f, :password, required: true %> - - <%= label f, :remember_me, "Keep me logged in for 60 days" %> - <%= checkbox f, :remember_me %> - -
- <%= submit "Log in" %> -
- - -

- <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | - <%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %> -

diff --git a/lib/something_erlang_web/templates/user_settings/edit.html.heex b/lib/something_erlang_web/templates/user_settings/edit.html.heex deleted file mode 100644 index 5b8a40c..0000000 --- a/lib/something_erlang_web/templates/user_settings/edit.html.heex +++ /dev/null @@ -1,79 +0,0 @@ -

Settings

- -

Change SA data

- -<.form let={f} for={@sadata_changeset} - action={Routes.user_settings_path(@conn, :update)} - id="update_sadata"> - <%= if @sadata_changeset.action do %> -
-

Oops, something went wrong! Please check the errors below.

-
- <% end %> - - <%= hidden_input f, :action, name: "action", value: "update_sadata" %> - - <%= label f, :bbuserid %> - <%= text_input f, :bbuserid, required: true %> - <%= error_tag f, :bbuserid %> - - <%= label f, :bbpassword %> - <%= text_input f, :bbpassword, required: true %> - <%= error_tag f, :bbpassword %> - -
- <%= submit "Change sadata", class: "btn" %> -
- - -

Change email

- -<.form let={f} for={@email_changeset} action={Routes.user_settings_path(@conn, :update)} id="update_email"> - <%= if @email_changeset.action do %> -
-

Oops, something went wrong! Please check the errors below.

-
- <% end %> - - <%= hidden_input f, :action, name: "action", value: "update_email" %> - - <%= label f, :email %> - <%= email_input f, :email, required: true %> - <%= error_tag f, :email %> - - <%= label f, :current_password, for: "current_password_for_email" %> - <%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_email" %> - <%= error_tag f, :current_password %> - -
- <%= submit "Change email", class: "btn" %> -
- - -

Change password

- -<.form let={f} for={@password_changeset} action={Routes.user_settings_path(@conn, :update)} id="update_password"> - <%= if @password_changeset.action do %> -
-

Oops, something went wrong! Please check the errors below.

-
- <% end %> - - <%= hidden_input f, :action, name: "action", value: "update_password" %> - - <%= label f, :password, "New password" %> - <%= password_input f, :password, required: true %> - <%= error_tag f, :password %> - - <%= label f, :password_confirmation, "Confirm new password" %> - <%= password_input f, :password_confirmation, required: true %> - <%= error_tag f, :password_confirmation %> - - <%= label f, :current_password, for: "current_password_for_password" %> - <%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_password" %> - <%= error_tag f, :current_password %> - -
- <%= submit "Change password", class: "btn" %> -
- diff --git a/lib/something_erlang_web/controllers/user_auth.ex b/lib/something_erlang_web/user_auth.ex similarity index 59% rename from lib/something_erlang_web/controllers/user_auth.ex rename to lib/something_erlang_web/user_auth.ex index 0362bf6..9b28f04 100644 --- a/lib/something_erlang_web/controllers/user_auth.ex +++ b/lib/something_erlang_web/user_auth.ex @@ -1,9 +1,10 @@ defmodule SomethingErlangWeb.UserAuth do + use SomethingErlangWeb, :verified_routes + import Plug.Conn import Phoenix.Controller alias SomethingErlang.Accounts - alias SomethingErlangWeb.Router.Helpers, as: Routes # Make the remember me cookie valid for 60 days. # If you want bump or reduce this value, also change @@ -30,8 +31,7 @@ defmodule SomethingErlangWeb.UserAuth do conn |> renew_session() - |> put_session(:user_token, token) - |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") + |> put_token_in_session(token) |> maybe_write_remember_me_cookie(token, params) |> redirect(to: user_return_to || signed_in_path(conn)) end @@ -72,7 +72,7 @@ defmodule SomethingErlangWeb.UserAuth do """ def log_out_user(conn) do user_token = get_session(conn, :user_token) - user_token && Accounts.delete_session_token(user_token) + user_token && Accounts.delete_user_session_token(user_token) if live_socket_id = get_session(conn, :live_socket_id) do SomethingErlangWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) @@ -95,19 +95,95 @@ defmodule SomethingErlangWeb.UserAuth do end defp ensure_user_token(conn) do - if user_token = get_session(conn, :user_token) do - {user_token, conn} + if token = get_session(conn, :user_token) do + {token, conn} else conn = fetch_cookies(conn, signed: [@remember_me_cookie]) - if user_token = conn.cookies[@remember_me_cookie] do - {user_token, put_session(conn, :user_token, user_token)} + if token = conn.cookies[@remember_me_cookie] do + {token, put_token_in_session(conn, token)} else {nil, conn} end end end + @doc """ + Handles mounting and authenticating the current_user in LiveViews. + + ## `on_mount` arguments + + * `:mount_current_user` - Assigns current_user + to socket assigns based on user_token, or nil if + there's no user_token or no matching user. + + * `:ensure_authenticated` - Authenticates the user from the session, + and assigns the current_user to socket assigns based + on user_token. + Redirects to login page if there's no logged user. + + * `:redirect_if_user_is_authenticated` - Authenticates the user from the session. + Redirects to signed_in_path if there's a logged user. + + ## Examples + + Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate + the current_user: + + defmodule SomethingErlangWeb.PageLive do + use SomethingErlangWeb, :live_view + + on_mount {SomethingErlangWeb.UserAuth, :mount_current_user} + ... + end + + Or use the `live_session` of your router to invoke the on_mount callback: + + live_session :authenticated, on_mount: [{SomethingErlangWeb.UserAuth, :ensure_authenticated}] do + live "/profile", ProfileLive, :index + end + """ + def on_mount(:mount_current_user, _params, session, socket) do + {:cont, mount_current_user(session, socket)} + end + + def on_mount(:ensure_authenticated, _params, session, socket) do + socket = mount_current_user(session, socket) + + if socket.assigns.current_user do + {:cont, socket} + else + socket = + socket + |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") + |> Phoenix.LiveView.redirect(to: ~p"/users/log_in") + + {:halt, socket} + end + end + + def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do + socket = mount_current_user(session, socket) + + if socket.assigns.current_user do + {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))} + else + {:cont, socket} + end + end + + defp mount_current_user(session, socket) do + case session do + %{"user_token" => user_token} -> + Phoenix.Component.assign_new(socket, :current_user, fn -> + Accounts.get_user_by_session_token(user_token) + end) + + %{} -> + Phoenix.Component.assign_new(socket, :current_user, fn -> nil end) + end + end + @doc """ Used for routes that require the user to not be authenticated. """ @@ -134,16 +210,22 @@ defmodule SomethingErlangWeb.UserAuth do conn |> put_flash(:error, "You must log in to access this page.") |> maybe_store_return_to() - |> redirect(to: Routes.user_session_path(conn, :new)) + |> redirect(to: ~p"/users/log_in") |> halt() end end + defp put_token_in_session(conn, token) do + conn + |> put_session(:user_token, token) + |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") + end + defp maybe_store_return_to(%{method: "GET"} = conn) do put_session(conn, :user_return_to, current_path(conn)) end defp maybe_store_return_to(conn), do: conn - defp signed_in_path(_conn), do: "/" + defp signed_in_path(_conn), do: ~p"/" end diff --git a/lib/something_erlang_web/views/error_helpers.ex b/lib/something_erlang_web/views/error_helpers.ex deleted file mode 100644 index c6545ef..0000000 --- a/lib/something_erlang_web/views/error_helpers.ex +++ /dev/null @@ -1,47 +0,0 @@ -defmodule SomethingErlangWeb.ErrorHelpers do - @moduledoc """ - Conveniences for translating and building error messages. - """ - - use Phoenix.HTML - - @doc """ - Generates tag for inlined form input errors. - """ - def error_tag(form, field) do - Enum.map(Keyword.get_values(form.errors, field), fn error -> - content_tag(:span, translate_error(error), - class: "invalid-feedback", - phx_feedback_for: input_name(form, field) - ) - end) - end - - @doc """ - Translates an error message using gettext. - """ - def translate_error({msg, opts}) do - # When using gettext, we typically pass the strings we want - # to translate as a static argument: - # - # # Translate "is invalid" in the "errors" domain - # dgettext("errors", "is invalid") - # - # # Translate the number of files with plural rules - # dngettext("errors", "1 file", "%{count} files", count) - # - # Because the error messages we show in our forms and APIs - # are defined inside Ecto, we need to translate them dynamically. - # This requires us to call the Gettext module passing our gettext - # backend as first argument. - # - # Note we use the "errors" domain, which means translations - # should be written to the errors.po file. The :count option is - # set by Ecto and indicates we should also apply plural rules. - if count = opts[:count] do - Gettext.dngettext(SomethingErlangWeb.Gettext, "errors", msg, msg, count, opts) - else - Gettext.dgettext(SomethingErlangWeb.Gettext, "errors", msg, opts) - end - end -end diff --git a/lib/something_erlang_web/views/error_view.ex b/lib/something_erlang_web/views/error_view.ex deleted file mode 100644 index 6fefd88..0000000 --- a/lib/something_erlang_web/views/error_view.ex +++ /dev/null @@ -1,16 +0,0 @@ -defmodule SomethingErlangWeb.ErrorView do - use SomethingErlangWeb, :view - - # If you want to customize a particular status code - # for a certain format, you may uncomment below. - # def render("500.html", _assigns) do - # "Internal Server Error" - # end - - # By default, Phoenix returns the status message from - # the template name. For example, "404.html" becomes - # "Not Found". - def template_not_found(template, _assigns) do - Phoenix.Controller.status_message_from_template(template) - end -end diff --git a/lib/something_erlang_web/views/layout_view.ex b/lib/something_erlang_web/views/layout_view.ex deleted file mode 100644 index cbc9499..0000000 --- a/lib/something_erlang_web/views/layout_view.ex +++ /dev/null @@ -1,7 +0,0 @@ -defmodule SomethingErlangWeb.LayoutView do - use SomethingErlangWeb, :view - - # Phoenix LiveDashboard is available only in development by default, - # so we instruct Elixir to not warn if the dashboard route is missing. - @compile {:no_warn_undefined, {Routes, :live_dashboard_path, 2}} -end diff --git a/lib/something_erlang_web/views/page_view.ex b/lib/something_erlang_web/views/page_view.ex deleted file mode 100644 index 11231d3..0000000 --- a/lib/something_erlang_web/views/page_view.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule SomethingErlangWeb.PageView do - use SomethingErlangWeb, :view -end diff --git a/lib/something_erlang_web/views/user_confirmation_view.ex b/lib/something_erlang_web/views/user_confirmation_view.ex deleted file mode 100644 index 32ccb1e..0000000 --- a/lib/something_erlang_web/views/user_confirmation_view.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule SomethingErlangWeb.UserConfirmationView do - use SomethingErlangWeb, :view -end diff --git a/lib/something_erlang_web/views/user_registration_view.ex b/lib/something_erlang_web/views/user_registration_view.ex deleted file mode 100644 index f294204..0000000 --- a/lib/something_erlang_web/views/user_registration_view.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule SomethingErlangWeb.UserRegistrationView do - use SomethingErlangWeb, :view -end diff --git a/lib/something_erlang_web/views/user_reset_password_view.ex b/lib/something_erlang_web/views/user_reset_password_view.ex deleted file mode 100644 index ee1b99d..0000000 --- a/lib/something_erlang_web/views/user_reset_password_view.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule SomethingErlangWeb.UserResetPasswordView do - use SomethingErlangWeb, :view -end diff --git a/lib/something_erlang_web/views/user_session_view.ex b/lib/something_erlang_web/views/user_session_view.ex deleted file mode 100644 index 680ff44..0000000 --- a/lib/something_erlang_web/views/user_session_view.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule SomethingErlangWeb.UserSessionView do - use SomethingErlangWeb, :view -end diff --git a/lib/something_erlang_web/views/user_settings_view.ex b/lib/something_erlang_web/views/user_settings_view.ex deleted file mode 100644 index f5253a1..0000000 --- a/lib/something_erlang_web/views/user_settings_view.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule SomethingErlangWeb.UserSettingsView do - use SomethingErlangWeb, :view -end diff --git a/mix.exs b/mix.exs index 29cbcd2..71d8a3c 100644 --- a/mix.exs +++ b/mix.exs @@ -5,9 +5,8 @@ defmodule SomethingErlang.MixProject do [ app: :something_erlang, version: "0.1.0", - elixir: "~> 1.12", + elixir: "~> 1.14", elixirc_paths: elixirc_paths(Mix.env()), - compilers: [:gettext] ++ Mix.compilers(), start_permanent: Mix.env() == :prod, aliases: aliases(), deps: deps() @@ -34,25 +33,27 @@ defmodule SomethingErlang.MixProject do defp deps do [ {:bcrypt_elixir, "~> 3.0"}, - {:phoenix, "~> 1.6.9"}, + {:phoenix, "~> 1.7.0-rc.2", override: true}, {:phoenix_ecto, "~> 4.4"}, {:ecto_sql, "~> 3.6"}, {:postgrex, ">= 0.0.0"}, {:phoenix_html, "~> 3.0"}, {:phoenix_live_reload, "~> 1.2", only: :dev}, - {:phoenix_live_view, "~> 0.17.5"}, + {:phoenix_live_view, "~> 0.18.3"}, + {:heroicons, "~> 0.5"}, {:floki, ">= 0.30.0"}, - {:phoenix_live_dashboard, "~> 0.6"}, - {:esbuild, "~> 0.4", runtime: Mix.env() == :dev}, + {:phoenix_live_dashboard, "~> 0.7.2"}, + {:esbuild, "~> 0.5", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.1.8", runtime: Mix.env() == :dev}, {:swoosh, "~> 1.3"}, + {:finch, "~> 0.13"}, {:telemetry_metrics, "~> 0.6"}, {:telemetry_poller, "~> 1.0"}, - {:gettext, "~> 0.18"}, + {:gettext, "~> 0.20"}, {:jason, "~> 1.2"}, - {:plug_cowboy, "~> 2.5"}, - {:tailwind, "~> 0.1", runtime: Mix.env() == :dev}, + {:bandit, ">= 0.6.7"}, {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, - {:req, "~> 0.3.0"} + {:req, "~> 0.3"} ] end @@ -64,15 +65,12 @@ defmodule SomethingErlang.MixProject do # See the documentation for `Mix` for more info on aliases. defp aliases do [ - setup: ["deps.get", "ecto.setup"], + setup: ["deps.get", "ecto.setup", "assets.setup"], "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], - "assets.deploy": [ - "tailwind default --minify", - "esbuild default --minify", - "phx.digest" - ] + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"] ] end end diff --git a/mix.lock b/mix.lock index 99d5d77..76a8ec9 100644 --- a/mix.lock +++ b/mix.lock @@ -1,57 +1,52 @@ %{ + "bandit": {:hex, :bandit, "0.6.7", "8d768a512ecbda9bd4e71fe223fce8d57c30899ede61dc70a0d4d34407910a8e", [:mix], [{:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:thousand_island, "~> 0.5.14", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.4.3", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "0f1e9ac0e09714ee54c76001fe9d973aa25bff9cd058668c8f6cd0152f8ca3cf"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.0.1", "9be815469e6bfefec40fa74658ecbbe6897acfb57614df1416eeccd4903f602c", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "486bb95efb645d1efc6794c1ddd776a186a9a713abf06f45708a6ce324fb96cf"}, - "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, - "castore": {:hex, :castore, "0.1.17", "ba672681de4e51ed8ec1f74ed624d104c0db72742ea1a5e74edbc770c815182f", [:mix], [], "hexpm", "d9844227ed52d26e7519224525cb6868650c272d4a3d327ce3ca5570c12163f9"}, - "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, - "codepagex": {:hex, :codepagex, "0.1.6", "49110d09a25ee336a983281a48ef883da4c6190481e0b063afe2db481af6117e", [:mix], [], "hexpm", "1521461097dde281edf084062f525a4edc6a5e49f4fd1f5ec41c9c4955d5bd59"}, + "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, + "castore": {:hex, :castore, "0.1.22", "4127549e411bedd012ca3a308dede574f43819fe9394254ca55ab4895abfa1a2", [:mix], [], "hexpm", "c17576df47eb5aa1ee40cc4134316a99f5cad3e215d5c77b8dd3cfef12a22cac"}, "comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"}, "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, - "credo": {:hex, :credo, "1.6.5", "330ca591c12244ab95498d8f47994c493064b2689febf1236d43d596b4f2261d", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "101de53e6907397c3246ccd2cc9b9f0d3fc0b7805b8e1c1c3d818471fc85bafd"}, - "db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"}, + "credo": {:hex, :credo, "1.6.7", "323f5734350fd23a456f2688b9430e7d517afb313fbd38671b8a4449798a7854", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "41e110bfb007f7eda7f897c10bf019ceab9a0b269ce79f015d54b0dcf4fc7dd3"}, + "db_connection": {:hex, :db_connection, "2.4.3", "3b9aac9f27347ec65b271847e6baeb4443d8474289bd18c1d6f4de655b70c94d", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c127c15b0fa6cfb32eed07465e05da6c815b032508d4ed7c116122871df73c12"}, "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, - "ecto": {:hex, :ecto, "3.8.4", "e06b8b87e62b27fea17fd2ff6041572ddd10339fd16cdf58446e402c6c90a74b", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f9244288b8d42db40515463a008cf3f4e0e564bb9c249fe87bf28a6d79fe82d4"}, - "ecto_sql": {:hex, :ecto_sql, "3.8.3", "a7d22c624202546a39d615ed7a6b784580391e65723f2d24f65941b4dd73d471", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.8.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "348cb17fb9e6daf6f251a87049eafcb57805e2892e5e6a0f5dea0985d367329b"}, - "elixir_make": {:hex, :elixir_make, "0.6.3", "bc07d53221216838d79e03a8019d0839786703129599e9619f4ab74c8c096eac", [:mix], [], "hexpm", "f5cbd651c5678bcaabdbb7857658ee106b12509cd976c2c2fca99688e1daf716"}, - "esbuild": {:hex, :esbuild, "0.5.0", "d5bb08ff049d7880ee3609ed5c4b864bd2f46445ea40b16b4acead724fb4c4a3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "f183a0b332d963c4cfaf585477695ea59eef9a6f2204fdd0efa00e099694ffe5"}, + "ecto": {:hex, :ecto, "3.9.4", "3ee68e25dbe0c36f980f1ba5dd41ee0d3eb0873bccae8aeaf1a2647242bffa35", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "de5f988c142a3aa4ec18b85a4ec34a2390b65b24f02385c1144252ff6ff8ee75"}, + "ecto_sql": {:hex, :ecto_sql, "3.9.2", "34227501abe92dba10d9c3495ab6770e75e79b836d114c41108a4bf2ce200ad5", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1eb5eeb4358fdbcd42eac11c1fbd87e3affd7904e639d77903c1358b2abd3f70"}, + "elixir_make": {:hex, :elixir_make, "0.7.3", "c37fdae1b52d2cc51069713a58c2314877c1ad40800a57efb213f77b078a460d", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "24ada3e3996adbed1fa024ca14995ef2ba3d0d17b678b0f3f2b1f66e6ce2b274"}, + "esbuild": {:hex, :esbuild, "0.6.0", "9ba6ead054abd43cb3d7b14946a0cdd1493698ccd8e054e0e5d6286d7f0f509c", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "30f9a05d4a5bab0d3e37398f312f80864e1ee1a081ca09149d06d474318fd040"}, + "expo": {:hex, :expo, "0.1.0", "d4e932bdad052c374118e312e35280f1919ac13881cb3ac07a209a54d0c81dd8", [:mix], [], "hexpm", "c22c536021c56de058aaeedeabb4744eb5d48137bacf8c29f04d25b6c6bbbf45"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, - "finch": {:hex, :finch, "0.13.0", "c881e5460ec563bf02d4f4584079e62201db676ed4c0ef3e59189331c4eddf7b", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "49957dcde10dcdc042a123a507a9c5ec5a803f53646d451db2f7dea696fba6cc"}, - "floki": {:hex, :floki, "0.33.1", "f20f1eb471e726342b45ccb68edb9486729e7df94da403936ea94a794f072781", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "461035fd125f13fdf30f243c85a0b1e50afbec876cbf1ceefe6fddd2e6d712c6"}, - "gettext": {:hex, :gettext, "0.19.1", "564953fd21f29358e68b91634799d9d26989f8d039d7512622efb3c3b1c97892", [:mix], [], "hexpm", "10c656c0912b8299adba9b061c06947511e3f109ab0d18b44a866a4498e77222"}, - "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, - "hpax": {:hex, :hpax, "0.1.1", "2396c313683ada39e98c20a75a82911592b47e5c24391363343bde74f82396ca", [:mix], [], "hexpm", "0ae7d5a0b04a8a60caf7a39fcf3ec476f35cc2cc16c05abea730d3ce6ac6c826"}, - "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, - "httpoison": {:hex, :httpoison, "1.8.1", "df030d96de89dad2e9983f92b0c506a642d4b1f4a819c96ff77d12796189c63e", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "35156a6d678d6d516b9229e208942c405cf21232edd632327ecfaf4fd03e79e0"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, - "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "finch": {:hex, :finch, "0.14.0", "619bfdee18fc135190bf590356c4bf5d5f71f916adb12aec94caa3fa9267a4bc", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5459acaf18c4fdb47a8c22fb3baff5d8173106217c8e56c5ba0b93e66501a8dd"}, + "floki": {:hex, :floki, "0.34.0", "002d0cc194b48794d74711731db004fafeb328fe676976f160685262d43706a8", [:mix], [], "hexpm", "9c3a9f43f40dde00332a589bd9d389b90c1f518aef500364d00636acc5ebc99c"}, + "gettext": {:hex, :gettext, "0.21.0", "15bbceb20b317b706a8041061a08e858b5a189654128618b53746bf36c84352b", [:mix], [{:expo, "~> 0.1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "04a66db4103b6d1d18f92240bb2c73167b517229316b7bef84e4eebbfb2f14f6"}, + "heroicons": {:hex, :heroicons, "0.5.2", "a7ae72460ecc4b74a4ba9e72f0b5ac3c6897ad08968258597da11c2b0b210683", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "7ef96f455c1c136c335f1da0f1d7b12c34002c80a224ad96fc0ebf841a6ffef5"}, + "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, + "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, + "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, "mint": {:hex, :mint, "1.4.2", "50330223429a6e1260b2ca5415f69b0ab086141bc76dc2fbf34d7c389a6675b2", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "ce75a5bbcc59b4d7d8d70f8b2fc284b1751ffb35c7b6a6302b5192f8ab4ddd80"}, - "nimble_options": {:hex, :nimble_options, "0.4.0", "c89babbab52221a24b8d1ff9e7d838be70f0d871be823165c94dd3418eea728f", [:mix], [], "hexpm", "e6701c1af326a11eea9634a3b1c62b475339ace9456c1a23ec3bc9a847bca02d"}, + "nimble_options": {:hex, :nimble_options, "0.5.2", "42703307b924880f8c08d97719da7472673391905f528259915782bb346e0a1b", [:mix], [], "hexpm", "4da7f904b915fd71db549bcdc25f8d56f378ef7ae07dc1d372cbe72ba950dce0"}, "nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"}, - "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, - "phoenix": {:hex, :phoenix, "1.6.11", "29f3c0fd12fa1fc4d4b05e341578e55bc78d96ea83a022587a7e276884d397e4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1664e34f80c25ea4918fbadd957f491225ef601c0e00b4e644b1a772864bfbc2"}, + "phoenix": {:hex, :phoenix, "1.7.0-rc.2", "8faaff6f699aad2fe6a003c627da65d0864c868a4c10973ff90abfd7286c1f27", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.4", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "71abde2f67330c55b625dcc0e42bf76662dbadc7553c4f545c2f3759f40f7487"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, - "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.6.5", "1495bb014be12c9a9252eca04b9af54246f6b5c1e4cd1f30210cd00ec540cf8e", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.3", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.17.7", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "ef4fa50dd78364409039c99cf6f98ab5209b4c5f8796c17f4db118324f0db852"}, - "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "0.17.11", "205f6aa5405648c76f2abcd57716f42fc07d8f21dd8ea7b262dd12b324b50c95", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7177791944b7f90ed18f5935a6a5f07f760b36f7b3bdfb9d28c57440a3c43f99"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.7.2", "97cc4ff2dba1ebe504db72cb45098cb8e91f11160528b980bd282cc45c73b29c", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.3", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "0e5fdf063c7a3b620c566a30fcf68b7ee02e5e46fe48ee46a6ec3ba382dc05b7"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.9", "476264587c780ccd01a6ba7bae5d8c24e2dbe6eb9e56bc38df884c01ca47012b", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0dd1444cad2028872eafcbef80d0382c53540265b971afbe671918e0eafe511"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, - "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, - "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, - "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, - "postgrex": {:hex, :postgrex, "0.16.3", "fac79a81a9a234b11c44235a4494d8565303fa4b9147acf57e48978a074971db", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "aeaae1d2d1322da4e5fe90d241b0a564ce03a3add09d7270fb85362166194590"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.0", "c57bc5044f25f007dc86ab21895688c098a9f846a8dda6bc40e2d0ddc146e38f", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "1b066f99a26fd22064c12b2600a9a6e56700f591bf7b20b418054ea38b4d4357"}, + "plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "d1cf12ff96a1ca4f52207c5271a6c351a4733f413803488d75b70ccf44aebec2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "073cf20b753ce6682ed72905cd62a2d4bd9bad1bf9f7feb02a1b8e525bd94fa6"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, + "postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, - "req": {:hex, :req, "0.3.0", "45944bfa0ea21294ad269e2025b9983dd084cc89125c4fc0a8de8a4e7869486b", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "1212a3e047eede0fa7eeb84c30d08206d44bb120df98b6f6b9a9e04910954a71"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, - "swoosh": {:hex, :swoosh, "1.7.3", "febb47c8c3ce76747eb9e3ea25ed694c815f72069127e3bb039b7724082ec670", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "76abac313f95b6825baa8ceec269d597e8395950c928742fc6451d3456ca256d"}, - "tailwind": {:hex, :tailwind, "0.1.8", "3762defebc8e328fb19ff1afb8c37723e53b52be5ca74f0b8d0a02d1f3f432cf", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "40061d1bf2c0505c6b87be7a3ed05243fc10f6e1af4bac3336db8358bc84d4cc"}, - "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, + "req": {:hex, :req, "0.3.4", "a485fd02ea1c5aa24e80ca67e5d66aa9730bad78a6e5cd38345172b50d259ee6", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "11391a99b9425a2126f7a44340506afd5c3e3e68353d7342546dc3c23c5c514d"}, + "swoosh": {:hex, :swoosh, "1.9.1", "0a5d7bf9954eb41d7e55525bc0940379982b090abbaef67cd8e1fd2ed7f8ca1a", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "76dffff3ffcab80f249d5937a592eaef7cc49ac6f4cdd27e622868326ed6371e"}, + "tailwind": {:hex, :tailwind, "0.1.9", "25ba09d42f7bfabe170eb67683a76d6ec2061952dc9bd263a52a99ba3d24bd4d", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "9213f87709c458aaec313bb5f2df2b4d2cedc2b630e4ae821bf3c54c47a56d0b"}, + "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "thousand_island": {:hex, :thousand_island, "0.5.15", "3163c8b61c5e985a80e330d8544c4409e6039a1796587b812385051291b25361", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7347a922f7c8ae3f36737455c6539bba37e3e37c17cde20f9bac3fd0367a52f"}, + "websock": {:hex, :websock, "0.4.3", "184ac396bdcd3dfceb5b74c17d221af659dd559a95b1b92041ecb51c9b728093", [:mix], [], "hexpm", "5e4dd85f305f43fd3d3e25d70bec4a45228dfed60f0f3b072d8eddff335539cf"}, + "websock_adapter": {:hex, :websock_adapter, "0.4.5", "30038a3715067f51a9580562c05a3a8d501126030336ffc6edb53bf57d6d2d26", [:mix], [{:bandit, "~> 0.6", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.4", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "1d9812dc7e703c205049426fd4fe0852a247a825f91b099e53dc96f68bafe4c8"}, } diff --git a/notebooks/client.livemd b/notebooks/client.livemd deleted file mode 100644 index a534c03..0000000 --- a/notebooks/client.livemd +++ /dev/null @@ -1,7 +0,0 @@ -# Client - -## Section - -```elixir -SomethingErlangWeb -``` diff --git a/notebooks/something_erlang.livemd b/notebooks/something_erlang.livemd deleted file mode 100644 index 2f7e79a..0000000 --- a/notebooks/something_erlang.livemd +++ /dev/null @@ -1,80 +0,0 @@ -# Something Erlang - -## Intro - -It's nice. - -## Routes - -```elixir -alias SomethingErlangWeb.Router.Helpers, as: Routes -``` - -```elixir -initial_state = %{ - lv_pid: 123, - thread_id: 123_456, - page_number: 1 -} - -%{initial_state | page_number: 23} -``` - -## Grover's GenServer - -```elixir -DynamicSupervisor.count_children(SomethingErlang.Supervisor.Grovers) -``` - -```elixir -SomethingErlang.Grover.mount(%{bbuserid: 12345, bbpassword: "deadbeaf"}) -``` - -## Client stuff - -```elixir -defmodule Client do - def cookies(args) when is_map(args) do - Enum.map_join(args, ";", fn {k, v} -> "#{k}=#{v}" end) - end -end - -Client.cookies(%{a: "123", b: "anc"}) -``` - -```elixir -SomethingErlang.Accounts.get_user!(1) -``` - -```elixir -user = %{id: "162235", hash: "1542e8ab8b6cf65b766a32220143b97f"} -SomethingErlang.AwfulApi.parsed_thread(3_898_279, 51, user) -``` - - - -## Bookmarks - -```elixir -doc = SomethingErlang.AwfulApi.Client.bookmarks_doc(1, user) -html = Floki.parse_document!(doc) - -for td <- Floki.find(html, "tr.thread td") do - case td do - {"td", [{"class", <<"icon", _rest::binary>>} | _attrs], _} -> "icon" - {"td", attrs, _} -> attrs - end -end -``` - -```elixir -bookmarks = SomethingErlang.AwfulApi.bookmarks(user) -``` - -```elixir -url = SomethingErlang.AwfulApi.Client.thread_lastseen_page(3_898_279, user) -``` - -```elixir -url = SomethingErlang.AwfulApi.Client.thread_lastseen_page(3_898_279, user) -``` diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot index 39a220b..ccf5c68 100644 --- a/priv/gettext/errors.pot +++ b/priv/gettext/errors.pot @@ -48,18 +48,18 @@ msgid "are still associated with this entry" msgstr "" ## From Ecto.Changeset.validate_length/3 -msgid "should be %{count} character(s)" -msgid_plural "should be %{count} character(s)" -msgstr[0] "" -msgstr[1] "" - msgid "should have %{count} item(s)" msgid_plural "should have %{count} item(s)" msgstr[0] "" msgstr[1] "" -msgid "should be at least %{count} character(s)" -msgid_plural "should be at least %{count} character(s)" +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" msgstr[0] "" msgstr[1] "" @@ -68,8 +68,13 @@ msgid_plural "should have at least %{count} item(s)" msgstr[0] "" msgstr[1] "" -msgid "should be at most %{count} character(s)" -msgid_plural "should be at most %{count} character(s)" +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" msgstr[0] "" msgstr[1] "" @@ -78,6 +83,16 @@ msgid_plural "should have at most %{count} item(s)" msgstr[0] "" msgstr[1] "" +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + ## From Ecto.Changeset.validate_number/3 msgid "must be less than %{number}" msgstr "" diff --git a/priv/repo/migrations/20220523092454_create_threads.exs b/priv/repo/migrations/20220523092454_create_threads.exs deleted file mode 100644 index 08c3d57..0000000 --- a/priv/repo/migrations/20220523092454_create_threads.exs +++ /dev/null @@ -1,12 +0,0 @@ -defmodule SomethingErlang.Repo.Migrations.CreateThreads do - use Ecto.Migration - - def change do - create table(:threads) do - add :title, :string - add :thread_id, :integer - - timestamps() - end - end -end diff --git a/priv/repo/migrations/20220718094805_users_add_sadata.exs b/priv/repo/migrations/20220718094805_users_add_sadata.exs deleted file mode 100644 index ecb6fe0..0000000 --- a/priv/repo/migrations/20220718094805_users_add_sadata.exs +++ /dev/null @@ -1,10 +0,0 @@ -defmodule SomethingErlang.Repo.Migrations.UsersAddSadata do - use Ecto.Migration - - def change do - alter table("users") do - add :bbuserid, :string - add :bbpassword, :string - end - end -end diff --git a/priv/repo/migrations/20220523091744_create_users_auth_tables.exs b/priv/repo/migrations/20230118110156_create_users_auth_tables.exs similarity index 100% rename from priv/repo/migrations/20220523091744_create_users_auth_tables.exs rename to priv/repo/migrations/20230118110156_create_users_auth_tables.exs diff --git a/priv/static/favicon-a8ca4e3a2bb8fea46a9ee9e102e7d3eb.ico b/priv/static/favicon-a8ca4e3a2bb8fea46a9ee9e102e7d3eb.ico deleted file mode 100644 index 73de524aaadcf60fbe9d32881db0aa86b58b5cb9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1258 zcmbtU%Wl&^6ul9sg3!VO2~~)NW!T2!q)pq594l>19};~iq*A(~jy-7&9*^-%6nBGw z4e$pDv0%-jU%&$K0f;YPj}U9NXzVl{OuAyEk?eENx#!+%&%D{&*_bQeE(5^a)~3?| zfR@}>W&q%0@bo(Xlz3-j4Nkw_`2he|hC-7agIW+nar zcbxUHJn;uj{aAQuXw#uMRRMW$|?n`J}HmnAF59l}{Slq1-W0%6yztf3-&K9OA2W;9L+ z=K+iC`dFf3MSkzx#$G=2P{E>LHwj22Tv?Z09UG`vk$*7FKm`?n96jC(QWEx@fRZTd zVrkLJzR5pgZ8@vmwDjv+X$}CseE7XfuP?uD mxu2hxF3rr&n}`4N@ae{Es|BT7t8ahG3ux>9j&r(p`0@`8X>Ml# diff --git a/priv/static/images/phoenix-5bd99a0d17dd41bc9d9bf6840abcc089.png b/priv/static/images/phoenix-5bd99a0d17dd41bc9d9bf6840abcc089.png deleted file mode 100644 index 9c81075f63d2151e6f40e9aa66f665749a87cc6a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13900 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 - Accounts.deliver_update_email_instructions(user, "current@example.com", url) + Accounts.deliver_user_update_email_instructions(user, "current@example.com", url) end) {:ok, token} = Base.url_decode64(token, padding: false) @@ -200,7 +200,7 @@ defmodule SomethingErlang.AccountsTest do token = extract_user_token(fn url -> - Accounts.deliver_update_email_instructions(%{user | email: email}, user.email, url) + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) end) %{user: user, token: token, email: email} @@ -353,11 +353,11 @@ defmodule SomethingErlang.AccountsTest do end end - describe "delete_session_token/1" do + describe "delete_user_session_token/1" do test "deletes the token" do user = user_fixture() token = Accounts.generate_user_session_token(user) - assert Accounts.delete_session_token(token) == :ok + assert Accounts.delete_user_session_token(token) == :ok refute Accounts.get_user_by_session_token(token) end end @@ -500,7 +500,7 @@ defmodule SomethingErlang.AccountsTest do end end - describe "inspect/2" do + describe "inspect/2 for the User module" do test "does not include password" do refute inspect(%User{password: "123456"}) =~ "password: \"123456\"" end diff --git a/test/something_erlang/forums_test.exs b/test/something_erlang/forums_test.exs deleted file mode 100644 index 01b8c8b..0000000 --- a/test/something_erlang/forums_test.exs +++ /dev/null @@ -1,61 +0,0 @@ -defmodule SomethingErlang.ForumsTest do - use SomethingErlang.DataCase - - alias SomethingErlang.Forums - - describe "threads" do - alias SomethingErlang.Forums.Thread - - import SomethingErlang.ForumsFixtures - - @invalid_attrs %{thread_id: nil, title: nil} - - test "list_threads/0 returns all threads" do - thread = thread_fixture() - assert Forums.list_threads() == [thread] - end - - test "get_thread!/1 returns the thread with given id" do - thread = thread_fixture() - assert Forums.get_thread!(thread.id) == thread - end - - test "create_thread/1 with valid data creates a thread" do - valid_attrs = %{thread_id: 42, title: "some title"} - - assert {:ok, %Thread{} = thread} = Forums.create_thread(valid_attrs) - assert thread.thread_id == 42 - assert thread.title == "some title" - end - - test "create_thread/1 with invalid data returns error changeset" do - assert {:error, %Ecto.Changeset{}} = Forums.create_thread(@invalid_attrs) - end - - test "update_thread/2 with valid data updates the thread" do - thread = thread_fixture() - update_attrs = %{thread_id: 43, title: "some updated title"} - - assert {:ok, %Thread{} = thread} = Forums.update_thread(thread, update_attrs) - assert thread.thread_id == 43 - assert thread.title == "some updated title" - end - - test "update_thread/2 with invalid data returns error changeset" do - thread = thread_fixture() - assert {:error, %Ecto.Changeset{}} = Forums.update_thread(thread, @invalid_attrs) - assert thread == Forums.get_thread!(thread.id) - end - - test "delete_thread/1 deletes the thread" do - thread = thread_fixture() - assert {:ok, %Thread{}} = Forums.delete_thread(thread) - assert_raise Ecto.NoResultsError, fn -> Forums.get_thread!(thread.id) end - end - - test "change_thread/1 returns a thread changeset" do - thread = thread_fixture() - assert %Ecto.Changeset{} = Forums.change_thread(thread) - end - end -end diff --git a/test/something_erlang_web/controllers/error_html_test.exs b/test/something_erlang_web/controllers/error_html_test.exs new file mode 100644 index 0000000..ba328b8 --- /dev/null +++ b/test/something_erlang_web/controllers/error_html_test.exs @@ -0,0 +1,14 @@ +defmodule SomethingErlangWeb.ErrorHTMLTest do + use SomethingErlangWeb.ConnCase, async: true + + # Bring render_to_string/4 for testing custom views + import Phoenix.Template + + test "renders 404.html" do + assert render_to_string(SomethingErlangWeb.ErrorHTML, "404", "html", []) == "Not Found" + end + + test "renders 500.html" do + assert render_to_string(SomethingErlangWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" + end +end diff --git a/test/something_erlang_web/controllers/error_json_test.exs b/test/something_erlang_web/controllers/error_json_test.exs new file mode 100644 index 0000000..23abfe5 --- /dev/null +++ b/test/something_erlang_web/controllers/error_json_test.exs @@ -0,0 +1,12 @@ +defmodule SomethingErlangWeb.ErrorJSONTest do + use SomethingErlangWeb.ConnCase, async: true + + test "renders 404" do + assert SomethingErlangWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} + end + + test "renders 500" do + assert SomethingErlangWeb.ErrorJSON.render("500.json", %{}) == + %{errors: %{detail: "Internal Server Error"}} + end +end diff --git a/test/something_erlang_web/controllers/page_controller_test.exs b/test/something_erlang_web/controllers/page_controller_test.exs index 209908a..08e2d89 100644 --- a/test/something_erlang_web/controllers/page_controller_test.exs +++ b/test/something_erlang_web/controllers/page_controller_test.exs @@ -2,7 +2,7 @@ defmodule SomethingErlangWeb.PageControllerTest do use SomethingErlangWeb.ConnCase test "GET /", %{conn: conn} do - conn = get(conn, "/") - assert html_response(conn, 200) =~ "Welcome to Phoenix!" + conn = get(conn, ~p"/") + assert html_response(conn, 200) =~ "Peace of mind from prototype to production" end end diff --git a/test/something_erlang_web/controllers/user_confirmation_controller_test.exs b/test/something_erlang_web/controllers/user_confirmation_controller_test.exs deleted file mode 100644 index ebdc5f7..0000000 --- a/test/something_erlang_web/controllers/user_confirmation_controller_test.exs +++ /dev/null @@ -1,105 +0,0 @@ -defmodule SomethingErlangWeb.UserConfirmationControllerTest do - use SomethingErlangWeb.ConnCase, async: true - - alias SomethingErlang.Accounts - alias SomethingErlang.Repo - import SomethingErlang.AccountsFixtures - - setup do - %{user: user_fixture()} - end - - describe "GET /users/confirm" do - test "renders the resend confirmation page", %{conn: conn} do - conn = get(conn, Routes.user_confirmation_path(conn, :new)) - response = html_response(conn, 200) - assert response =~ "

Resend confirmation instructions

" - end - end - - describe "POST /users/confirm" do - @tag :capture_log - test "sends a new confirmation token", %{conn: conn, user: user} do - conn = - post(conn, Routes.user_confirmation_path(conn, :create), %{ - "user" => %{"email" => user.email} - }) - - assert redirected_to(conn) == "/" - assert get_flash(conn, :info) =~ "If your email is in our system" - assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm" - end - - test "does not send confirmation token if User is confirmed", %{conn: conn, user: user} do - Repo.update!(Accounts.User.confirm_changeset(user)) - - conn = - post(conn, Routes.user_confirmation_path(conn, :create), %{ - "user" => %{"email" => user.email} - }) - - assert redirected_to(conn) == "/" - assert get_flash(conn, :info) =~ "If your email is in our system" - refute Repo.get_by(Accounts.UserToken, user_id: user.id) - end - - test "does not send confirmation token if email is invalid", %{conn: conn} do - conn = - post(conn, Routes.user_confirmation_path(conn, :create), %{ - "user" => %{"email" => "unknown@example.com"} - }) - - assert redirected_to(conn) == "/" - assert get_flash(conn, :info) =~ "If your email is in our system" - assert Repo.all(Accounts.UserToken) == [] - end - end - - describe "GET /users/confirm/:token" do - test "renders the confirmation page", %{conn: conn} do - conn = get(conn, Routes.user_confirmation_path(conn, :edit, "some-token")) - response = html_response(conn, 200) - assert response =~ "

Confirm account

" - - form_action = Routes.user_confirmation_path(conn, :update, "some-token") - assert response =~ "action=\"#{form_action}\"" - end - end - - describe "POST /users/confirm/:token" do - test "confirms the given token once", %{conn: conn, user: user} do - token = - extract_user_token(fn url -> - Accounts.deliver_user_confirmation_instructions(user, url) - end) - - conn = post(conn, Routes.user_confirmation_path(conn, :update, token)) - assert redirected_to(conn) == "/" - assert get_flash(conn, :info) =~ "User confirmed successfully" - assert Accounts.get_user!(user.id).confirmed_at - refute get_session(conn, :user_token) - assert Repo.all(Accounts.UserToken) == [] - - # When not logged in - conn = post(conn, Routes.user_confirmation_path(conn, :update, token)) - assert redirected_to(conn) == "/" - assert get_flash(conn, :error) =~ "User confirmation link is invalid or it has expired" - - # When logged in - conn = - build_conn() - |> log_in_user(user) - |> post(Routes.user_confirmation_path(conn, :update, token)) - - assert redirected_to(conn) == "/" - refute get_flash(conn, :error) - end - - test "does not confirm email with invalid token", %{conn: conn, user: user} do - conn = post(conn, Routes.user_confirmation_path(conn, :update, "oops")) - assert redirected_to(conn) == "/" - assert get_flash(conn, :error) =~ "User confirmation link is invalid or it has expired" - refute Accounts.get_user!(user.id).confirmed_at - end - end -end diff --git a/test/something_erlang_web/controllers/user_registration_controller_test.exs b/test/something_erlang_web/controllers/user_registration_controller_test.exs deleted file mode 100644 index 5c5af13..0000000 --- a/test/something_erlang_web/controllers/user_registration_controller_test.exs +++ /dev/null @@ -1,54 +0,0 @@ -defmodule SomethingErlangWeb.UserRegistrationControllerTest do - use SomethingErlangWeb.ConnCase, async: true - - import SomethingErlang.AccountsFixtures - - describe "GET /users/register" do - test "renders registration page", %{conn: conn} do - conn = get(conn, Routes.user_registration_path(conn, :new)) - response = html_response(conn, 200) - assert response =~ "

Register

" - assert response =~ "Log in" - assert response =~ "Register" - end - - test "redirects if already logged in", %{conn: conn} do - conn = conn |> log_in_user(user_fixture()) |> get(Routes.user_registration_path(conn, :new)) - assert redirected_to(conn) == "/" - end - end - - describe "POST /users/register" do - @tag :capture_log - test "creates account and logs the user in", %{conn: conn} do - email = unique_user_email() - - conn = - post(conn, Routes.user_registration_path(conn, :create), %{ - "user" => valid_user_attributes(email: email) - }) - - assert get_session(conn, :user_token) - assert redirected_to(conn) == "/" - - # Now do a logged in request and assert on the menu - conn = get(conn, "/") - response = html_response(conn, 200) - assert response =~ email - assert response =~ "Settings" - assert response =~ "Log out" - end - - test "render errors for invalid data", %{conn: conn} do - conn = - post(conn, Routes.user_registration_path(conn, :create), %{ - "user" => %{"email" => "with spaces", "password" => "too short"} - }) - - response = html_response(conn, 200) - assert response =~ "

Register

" - assert response =~ "must have the @ sign and no spaces" - assert response =~ "should be at least 12 character" - end - end -end diff --git a/test/something_erlang_web/controllers/user_reset_password_controller_test.exs b/test/something_erlang_web/controllers/user_reset_password_controller_test.exs deleted file mode 100644 index 92bf931..0000000 --- a/test/something_erlang_web/controllers/user_reset_password_controller_test.exs +++ /dev/null @@ -1,113 +0,0 @@ -defmodule SomethingErlangWeb.UserResetPasswordControllerTest do - use SomethingErlangWeb.ConnCase, async: true - - alias SomethingErlang.Accounts - alias SomethingErlang.Repo - import SomethingErlang.AccountsFixtures - - setup do - %{user: user_fixture()} - end - - describe "GET /users/reset_password" do - test "renders the reset password page", %{conn: conn} do - conn = get(conn, Routes.user_reset_password_path(conn, :new)) - response = html_response(conn, 200) - assert response =~ "

Forgot your password?

" - end - end - - describe "POST /users/reset_password" do - @tag :capture_log - test "sends a new reset password token", %{conn: conn, user: user} do - conn = - post(conn, Routes.user_reset_password_path(conn, :create), %{ - "user" => %{"email" => user.email} - }) - - assert redirected_to(conn) == "/" - assert get_flash(conn, :info) =~ "If your email is in our system" - assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "reset_password" - end - - test "does not send reset password token if email is invalid", %{conn: conn} do - conn = - post(conn, Routes.user_reset_password_path(conn, :create), %{ - "user" => %{"email" => "unknown@example.com"} - }) - - assert redirected_to(conn) == "/" - assert get_flash(conn, :info) =~ "If your email is in our system" - assert Repo.all(Accounts.UserToken) == [] - end - end - - describe "GET /users/reset_password/:token" do - setup %{user: user} do - token = - extract_user_token(fn url -> - Accounts.deliver_user_reset_password_instructions(user, url) - end) - - %{token: token} - end - - test "renders reset password", %{conn: conn, token: token} do - conn = get(conn, Routes.user_reset_password_path(conn, :edit, token)) - assert html_response(conn, 200) =~ "

Reset password

" - end - - test "does not render reset password with invalid token", %{conn: conn} do - conn = get(conn, Routes.user_reset_password_path(conn, :edit, "oops")) - assert redirected_to(conn) == "/" - assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired" - end - end - - describe "PUT /users/reset_password/:token" do - setup %{user: user} do - token = - extract_user_token(fn url -> - Accounts.deliver_user_reset_password_instructions(user, url) - end) - - %{token: token} - end - - test "resets password once", %{conn: conn, user: user, token: token} do - conn = - put(conn, Routes.user_reset_password_path(conn, :update, token), %{ - "user" => %{ - "password" => "new valid password", - "password_confirmation" => "new valid password" - } - }) - - assert redirected_to(conn) == Routes.user_session_path(conn, :new) - refute get_session(conn, :user_token) - assert get_flash(conn, :info) =~ "Password reset successfully" - assert Accounts.get_user_by_email_and_password(user.email, "new valid password") - end - - test "does not reset password on invalid data", %{conn: conn, token: token} do - conn = - put(conn, Routes.user_reset_password_path(conn, :update, token), %{ - "user" => %{ - "password" => "too short", - "password_confirmation" => "does not match" - } - }) - - response = html_response(conn, 200) - assert response =~ "

Reset password

" - assert response =~ "should be at least 12 character(s)" - assert response =~ "does not match password" - end - - test "does not reset password with invalid token", %{conn: conn} do - conn = put(conn, Routes.user_reset_password_path(conn, :update, "oops")) - assert redirected_to(conn) == "/" - assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired" - end - end -end diff --git a/test/something_erlang_web/controllers/user_session_controller_test.exs b/test/something_erlang_web/controllers/user_session_controller_test.exs index 1a0a9f4..bafb576 100644 --- a/test/something_erlang_web/controllers/user_session_controller_test.exs +++ b/test/something_erlang_web/controllers/user_session_controller_test.exs @@ -7,33 +7,18 @@ defmodule SomethingErlangWeb.UserSessionControllerTest do %{user: user_fixture()} end - describe "GET /users/log_in" do - test "renders log in page", %{conn: conn} do - conn = get(conn, Routes.user_session_path(conn, :new)) - response = html_response(conn, 200) - assert response =~ "

Log in

" - assert response =~ "Register" - assert response =~ "Forgot your password?" - end - - test "redirects if already logged in", %{conn: conn, user: user} do - conn = conn |> log_in_user(user) |> get(Routes.user_session_path(conn, :new)) - assert redirected_to(conn) == "/" - end - end - describe "POST /users/log_in" do test "logs the user in", %{conn: conn, user: user} do conn = - post(conn, Routes.user_session_path(conn, :create), %{ + post(conn, ~p"/users/log_in", %{ "user" => %{"email" => user.email, "password" => valid_user_password()} }) assert get_session(conn, :user_token) - assert redirected_to(conn) == "/" + assert redirected_to(conn) == ~p"/" # Now do a logged in request and assert on the menu - conn = get(conn, "/") + conn = get(conn, ~p"/") response = html_response(conn, 200) assert response =~ user.email assert response =~ "Settings" @@ -42,7 +27,7 @@ defmodule SomethingErlangWeb.UserSessionControllerTest do test "logs the user in with remember me", %{conn: conn, user: user} do conn = - post(conn, Routes.user_session_path(conn, :create), %{ + post(conn, ~p"/users/log_in", %{ "user" => %{ "email" => user.email, "password" => valid_user_password(), @@ -51,14 +36,14 @@ defmodule SomethingErlangWeb.UserSessionControllerTest do }) assert conn.resp_cookies["_something_erlang_web_user_remember_me"] - assert redirected_to(conn) == "/" + assert redirected_to(conn) == ~p"/" end test "logs the user in with return to", %{conn: conn, user: user} do conn = conn |> init_test_session(user_return_to: "/foo/bar") - |> post(Routes.user_session_path(conn, :create), %{ + |> post(~p"/users/log_in", %{ "user" => %{ "email" => user.email, "password" => valid_user_password() @@ -66,33 +51,63 @@ defmodule SomethingErlangWeb.UserSessionControllerTest do }) assert redirected_to(conn) == "/foo/bar" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!" end - test "emits error message with invalid credentials", %{conn: conn, user: user} do + test "login following registration", %{conn: conn, user: user} do conn = - post(conn, Routes.user_session_path(conn, :create), %{ - "user" => %{"email" => user.email, "password" => "invalid_password"} + conn + |> post(~p"/users/log_in", %{ + "_action" => "registered", + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } }) - response = html_response(conn, 200) - assert response =~ "

Log in

" - assert response =~ "Invalid email or password" + assert redirected_to(conn) == ~p"/" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Account created successfully" + end + + test "login following password update", %{conn: conn, user: user} do + conn = + conn + |> post(~p"/users/log_in", %{ + "_action" => "password_updated", + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == ~p"/users/settings" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password updated successfully" + end + + test "redirects to login page with invalid credentials", %{conn: conn} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => "invalid@email.com", "password" => "invalid_password"} + }) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" + assert redirected_to(conn) == ~p"/users/log_in" end end describe "DELETE /users/log_out" do test "logs the user out", %{conn: conn, user: user} do - conn = conn |> log_in_user(user) |> delete(Routes.user_session_path(conn, :delete)) - assert redirected_to(conn) == "/" + conn = conn |> log_in_user(user) |> delete(~p"/users/log_out") + assert redirected_to(conn) == ~p"/" refute get_session(conn, :user_token) - assert get_flash(conn, :info) =~ "Logged out successfully" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" end test "succeeds even if the user is not logged in", %{conn: conn} do - conn = delete(conn, Routes.user_session_path(conn, :delete)) - assert redirected_to(conn) == "/" + conn = delete(conn, ~p"/users/log_out") + assert redirected_to(conn) == ~p"/" refute get_session(conn, :user_token) - assert get_flash(conn, :info) =~ "Logged out successfully" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" end end end diff --git a/test/something_erlang_web/controllers/user_settings_controller_test.exs b/test/something_erlang_web/controllers/user_settings_controller_test.exs deleted file mode 100644 index 853d08d..0000000 --- a/test/something_erlang_web/controllers/user_settings_controller_test.exs +++ /dev/null @@ -1,129 +0,0 @@ -defmodule SomethingErlangWeb.UserSettingsControllerTest do - use SomethingErlangWeb.ConnCase, async: true - - alias SomethingErlang.Accounts - import SomethingErlang.AccountsFixtures - - setup :register_and_log_in_user - - describe "GET /users/settings" do - test "renders settings page", %{conn: conn} do - conn = get(conn, Routes.user_settings_path(conn, :edit)) - response = html_response(conn, 200) - assert response =~ "

Settings

" - end - - test "redirects if user is not logged in" do - conn = build_conn() - conn = get(conn, Routes.user_settings_path(conn, :edit)) - assert redirected_to(conn) == Routes.user_session_path(conn, :new) - end - end - - describe "PUT /users/settings (change password form)" do - test "updates the user password and resets tokens", %{conn: conn, user: user} do - new_password_conn = - put(conn, Routes.user_settings_path(conn, :update), %{ - "action" => "update_password", - "current_password" => valid_user_password(), - "user" => %{ - "password" => "new valid password", - "password_confirmation" => "new valid password" - } - }) - - assert redirected_to(new_password_conn) == Routes.user_settings_path(conn, :edit) - assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) - assert get_flash(new_password_conn, :info) =~ "Password updated successfully" - assert Accounts.get_user_by_email_and_password(user.email, "new valid password") - end - - test "does not update password on invalid data", %{conn: conn} do - old_password_conn = - put(conn, Routes.user_settings_path(conn, :update), %{ - "action" => "update_password", - "current_password" => "invalid", - "user" => %{ - "password" => "too short", - "password_confirmation" => "does not match" - } - }) - - response = html_response(old_password_conn, 200) - assert response =~ "

Settings

" - assert response =~ "should be at least 12 character(s)" - assert response =~ "does not match password" - assert response =~ "is not valid" - - assert get_session(old_password_conn, :user_token) == get_session(conn, :user_token) - end - end - - describe "PUT /users/settings (change email form)" do - @tag :capture_log - test "updates the user email", %{conn: conn, user: user} do - conn = - put(conn, Routes.user_settings_path(conn, :update), %{ - "action" => "update_email", - "current_password" => valid_user_password(), - "user" => %{"email" => unique_user_email()} - }) - - assert redirected_to(conn) == Routes.user_settings_path(conn, :edit) - assert get_flash(conn, :info) =~ "A link to confirm your email" - assert Accounts.get_user_by_email(user.email) - end - - test "does not update email on invalid data", %{conn: conn} do - conn = - put(conn, Routes.user_settings_path(conn, :update), %{ - "action" => "update_email", - "current_password" => "invalid", - "user" => %{"email" => "with spaces"} - }) - - response = html_response(conn, 200) - assert response =~ "

Settings

" - assert response =~ "must have the @ sign and no spaces" - assert response =~ "is not valid" - end - end - - describe "GET /users/settings/confirm_email/:token" do - setup %{user: user} do - email = unique_user_email() - - token = - extract_user_token(fn url -> - Accounts.deliver_update_email_instructions(%{user | email: email}, user.email, url) - end) - - %{token: token, email: email} - end - - test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do - conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) - assert redirected_to(conn) == Routes.user_settings_path(conn, :edit) - assert get_flash(conn, :info) =~ "Email changed successfully" - refute Accounts.get_user_by_email(user.email) - assert Accounts.get_user_by_email(email) - - conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) - assert redirected_to(conn) == Routes.user_settings_path(conn, :edit) - assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" - end - - test "does not update email with invalid token", %{conn: conn, user: user} do - conn = get(conn, Routes.user_settings_path(conn, :confirm_email, "oops")) - assert redirected_to(conn) == Routes.user_settings_path(conn, :edit) - assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" - assert Accounts.get_user_by_email(user.email) - end - - test "redirects if user is not logged in", %{token: token} do - conn = build_conn() - conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) - assert redirected_to(conn) == Routes.user_session_path(conn, :new) - end - end -end diff --git a/test/something_erlang_web/live/thread_live_test.exs b/test/something_erlang_web/live/thread_live_test.exs deleted file mode 100644 index b5ba1ac..0000000 --- a/test/something_erlang_web/live/thread_live_test.exs +++ /dev/null @@ -1,110 +0,0 @@ -defmodule SomethingErlangWeb.ThreadLiveTest do - use SomethingErlangWeb.ConnCase - - import Phoenix.LiveViewTest - import SomethingErlang.ForumsFixtures - - @create_attrs %{thread_id: 42, title: "some title"} - @update_attrs %{thread_id: 43, title: "some updated title"} - @invalid_attrs %{thread_id: nil, title: nil} - - defp create_thread(_) do - thread = thread_fixture() - %{thread: thread} - end - - describe "Index" do - setup [:create_thread] - - test "lists all threads", %{conn: conn, thread: thread} do - {:ok, _index_live, html} = live(conn, Routes.thread_index_path(conn, :index)) - - assert html =~ "Listing Threads" - assert html =~ thread.title - end - - test "saves new thread", %{conn: conn} do - {:ok, index_live, _html} = live(conn, Routes.thread_index_path(conn, :index)) - - assert index_live |> element("a", "New Thread") |> render_click() =~ - "New Thread" - - assert_patch(index_live, Routes.thread_index_path(conn, :new)) - - assert index_live - |> form("#thread-form", thread: @invalid_attrs) - |> render_change() =~ "can't be blank" - - {:ok, _, html} = - index_live - |> form("#thread-form", thread: @create_attrs) - |> render_submit() - |> follow_redirect(conn, Routes.thread_index_path(conn, :index)) - - assert html =~ "Thread created successfully" - assert html =~ "some title" - end - - test "updates thread in listing", %{conn: conn, thread: thread} do - {:ok, index_live, _html} = live(conn, Routes.thread_index_path(conn, :index)) - - assert index_live |> element("#thread-#{thread.id} a", "Edit") |> render_click() =~ - "Edit Thread" - - assert_patch(index_live, Routes.thread_index_path(conn, :edit, thread)) - - assert index_live - |> form("#thread-form", thread: @invalid_attrs) - |> render_change() =~ "can't be blank" - - {:ok, _, html} = - index_live - |> form("#thread-form", thread: @update_attrs) - |> render_submit() - |> follow_redirect(conn, Routes.thread_index_path(conn, :index)) - - assert html =~ "Thread updated successfully" - assert html =~ "some updated title" - end - - test "deletes thread in listing", %{conn: conn, thread: thread} do - {:ok, index_live, _html} = live(conn, Routes.thread_index_path(conn, :index)) - - assert index_live |> element("#thread-#{thread.id} a", "Delete") |> render_click() - refute has_element?(index_live, "#thread-#{thread.id}") - end - end - - describe "Show" do - setup [:create_thread] - - test "displays thread", %{conn: conn, thread: thread} do - {:ok, _show_live, html} = live(conn, Routes.thread_show_path(conn, :show, thread)) - - assert html =~ "Show Thread" - assert html =~ thread.title - end - - test "updates thread within modal", %{conn: conn, thread: thread} do - {:ok, show_live, _html} = live(conn, Routes.thread_show_path(conn, :show, thread)) - - assert show_live |> element("a", "Edit") |> render_click() =~ - "Edit Thread" - - assert_patch(show_live, Routes.thread_show_path(conn, :edit, thread)) - - assert show_live - |> form("#thread-form", thread: @invalid_attrs) - |> render_change() =~ "can't be blank" - - {:ok, _, html} = - show_live - |> form("#thread-form", thread: @update_attrs) - |> render_submit() - |> follow_redirect(conn, Routes.thread_show_path(conn, :show, thread)) - - assert html =~ "Thread updated successfully" - assert html =~ "some updated title" - end - end -end diff --git a/test/something_erlang_web/live/user_confirmation_instructions_live_test.exs b/test/something_erlang_web/live/user_confirmation_instructions_live_test.exs new file mode 100644 index 0000000..1b94a2d --- /dev/null +++ b/test/something_erlang_web/live/user_confirmation_instructions_live_test.exs @@ -0,0 +1,67 @@ +defmodule SomethingErlangWeb.UserConfirmationInstructionsLiveTest do + use SomethingErlangWeb.ConnCase + + import Phoenix.LiveViewTest + import SomethingErlang.AccountsFixtures + + alias SomethingErlang.Accounts + alias SomethingErlang.Repo + + setup do + %{user: user_fixture()} + end + + describe "Resend confirmation" do + test "renders the resend confirmation page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/confirm") + assert html =~ "Resend confirmation instructions" + end + + test "sends a new confirmation token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: user.email}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm" + end + + test "does not send confirmation token if user is confirmed", %{conn: conn, user: user} do + Repo.update!(Accounts.User.confirm_changeset(user)) + + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: user.email}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + refute Repo.get_by(Accounts.UserToken, user_id: user.id) + end + + test "does not send confirmation token if email is invalid", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: "unknown@example.com"}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + assert Repo.all(Accounts.UserToken) == [] + end + end +end diff --git a/test/something_erlang_web/live/user_confirmation_live_test.exs b/test/something_erlang_web/live/user_confirmation_live_test.exs new file mode 100644 index 0000000..59905a9 --- /dev/null +++ b/test/something_erlang_web/live/user_confirmation_live_test.exs @@ -0,0 +1,88 @@ +defmodule SomethingErlangWeb.UserConfirmationLiveTest do + use SomethingErlangWeb.ConnCase + + import Phoenix.LiveViewTest + import SomethingErlang.AccountsFixtures + + alias SomethingErlang.Accounts + alias SomethingErlang.Repo + + setup do + %{user: user_fixture()} + end + + describe "Confirm user" do + test "renders confirmation page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/confirm/some-token") + assert html =~ "Confirm Account" + end + + test "confirms the given token once", %{conn: conn, user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "User confirmed successfully" + + assert Accounts.get_user!(user.id).confirmed_at + refute get_session(conn, :user_token) + assert Repo.all(Accounts.UserToken) == [] + + # when not logged in + {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "User confirmation link is invalid or it has expired" + + # when logged in + {:ok, lv, _html} = + build_conn() + |> log_in_user(user) + |> live(~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + refute Phoenix.Flash.get(conn.assigns.flash, :error) + end + + test "does not confirm email with invalid token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm/invalid-token") + + {:ok, conn} = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "User confirmation link is invalid or it has expired" + + refute Accounts.get_user!(user.id).confirmed_at + end + end +end diff --git a/test/something_erlang_web/live/user_forgot_password_live_test.exs b/test/something_erlang_web/live/user_forgot_password_live_test.exs new file mode 100644 index 0000000..f103f20 --- /dev/null +++ b/test/something_erlang_web/live/user_forgot_password_live_test.exs @@ -0,0 +1,63 @@ +defmodule SomethingErlangWeb.UserForgotPasswordLiveTest do + use SomethingErlangWeb.ConnCase + + import Phoenix.LiveViewTest + import SomethingErlang.AccountsFixtures + + alias SomethingErlang.Accounts + alias SomethingErlang.Repo + + describe "Forgot password page" do + test "renders email page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/reset_password") + + assert html =~ "Forgot your password?" + assert html =~ "Register" + assert html =~ "Log in" + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/reset_password") + |> follow_redirect(conn, ~p"/") + + assert {:ok, _conn} = result + end + end + + describe "Reset link" do + setup do + %{user: user_fixture()} + end + + test "sends a new reset password token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password") + + {:ok, conn} = + lv + |> form("#reset_password_form", user: %{"email" => user.email}) + |> render_submit() + |> follow_redirect(conn, "/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" + + assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == + "reset_password" + end + + test "does not send reset password token if email is invalid", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password") + + {:ok, conn} = + lv + |> form("#reset_password_form", user: %{"email" => "unknown@example.com"}) + |> render_submit() + |> follow_redirect(conn, "/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" + assert Repo.all(Accounts.UserToken) == [] + end + end +end diff --git a/test/something_erlang_web/live/user_login_live_test.exs b/test/something_erlang_web/live/user_login_live_test.exs new file mode 100644 index 0000000..9f8add6 --- /dev/null +++ b/test/something_erlang_web/live/user_login_live_test.exs @@ -0,0 +1,87 @@ +defmodule SomethingErlangWeb.UserLoginLiveTest do + use SomethingErlangWeb.ConnCase + + import Phoenix.LiveViewTest + import SomethingErlang.AccountsFixtures + + describe "Log in page" do + test "renders log in page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/log_in") + + assert html =~ "Log in" + assert html =~ "Register" + assert html =~ "Forgot your password?" + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/log_in") + |> follow_redirect(conn, "/") + + assert {:ok, _conn} = result + end + end + + describe "user login" do + test "redirects if user login with valid credentials", %{conn: conn} do + password = "123456789abcd" + user = user_fixture(%{password: password}) + + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + form = + form(lv, "#login_form", user: %{email: user.email, password: password, remember_me: true}) + + conn = submit_form(form, conn) + + assert redirected_to(conn) == ~p"/" + end + + test "redirects to login page with a flash error if there are no valid credentials", %{ + conn: conn + } do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + form = + form(lv, "#login_form", + user: %{email: "test@email.com", password: "123456", remember_me: true} + ) + + conn = submit_form(form, conn) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" + + assert redirected_to(conn) == "/users/log_in" + end + end + + describe "login navigation" do + test "redirects to registration page when the Register button is clicked", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + {:ok, _login_live, login_html} = + lv + |> element(~s|a:fl-contains("Sign up")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/register") + + assert login_html =~ "Register" + end + + test "redirects to forgot password page when the Forgot Password button is clicked", %{ + conn: conn + } do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + {:ok, conn} = + lv + |> element(~s{a:fl-contains('Forgot your password?')}) + |> render_click() + |> follow_redirect(conn, ~p"/users/reset_password") + + assert conn.resp_body =~ "Forgot your password?" + end + end +end diff --git a/test/something_erlang_web/live/user_registration_live_test.exs b/test/something_erlang_web/live/user_registration_live_test.exs new file mode 100644 index 0000000..2cdb902 --- /dev/null +++ b/test/something_erlang_web/live/user_registration_live_test.exs @@ -0,0 +1,87 @@ +defmodule SomethingErlangWeb.UserRegistrationLiveTest do + use SomethingErlangWeb.ConnCase + + import Phoenix.LiveViewTest + import SomethingErlang.AccountsFixtures + + describe "Registration page" do + test "renders registration page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/register") + + assert html =~ "Register" + assert html =~ "Log in" + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/register") + |> follow_redirect(conn, "/") + + assert {:ok, _conn} = result + end + + test "renders errors for invalid data", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + result = + lv + |> element("#registration_form") + |> render_change(user: %{"email" => "with spaces", "password" => "too short"}) + + assert result =~ "Register" + assert result =~ "must have the @ sign and no spaces" + assert result =~ "should be at least 12 character" + end + end + + describe "register user" do + test "creates account and logs the user in", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + email = unique_user_email() + form = form(lv, "#registration_form", user: valid_user_attributes(email: email)) + render_submit(form) + conn = follow_trigger_action(form, conn) + + assert redirected_to(conn) == ~p"/" + + # Now do a logged in request and assert on the menu + conn = get(conn, "/") + response = html_response(conn, 200) + assert response =~ email + assert response =~ "Settings" + assert response =~ "Log out" + end + + test "renders errors for duplicated email", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + user = user_fixture(%{email: "test@email.com"}) + + result = + lv + |> form("#registration_form", + user: %{"email" => user.email, "password" => "valid_password"} + ) + |> render_submit() + + assert result =~ "has already been taken" + end + end + + describe "registration navigation" do + test "redirects to login page when the Log in button is clicked", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + {:ok, _login_live, login_html} = + lv + |> element(~s|main a:fl-contains("Sign in")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/log_in") + + assert login_html =~ "Log in" + end + end +end diff --git a/test/something_erlang_web/live/user_reset_password_live_test.exs b/test/something_erlang_web/live/user_reset_password_live_test.exs new file mode 100644 index 0000000..17fc8bd --- /dev/null +++ b/test/something_erlang_web/live/user_reset_password_live_test.exs @@ -0,0 +1,118 @@ +defmodule SomethingErlangWeb.UserResetPasswordLiveTest do + use SomethingErlangWeb.ConnCase + + import Phoenix.LiveViewTest + import SomethingErlang.AccountsFixtures + + alias SomethingErlang.Accounts + + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{token: token, user: user} + end + + describe "Reset password page" do + test "renders reset password with valid token", %{conn: conn, token: token} do + {:ok, _lv, html} = live(conn, ~p"/users/reset_password/#{token}") + + assert html =~ "Reset Password" + end + + test "does not render reset password with invalid token", %{conn: conn} do + {:error, {:redirect, to}} = live(conn, ~p"/users/reset_password/invalid") + + assert to == %{ + flash: %{"error" => "Reset password link is invalid or it has expired."}, + to: ~p"/" + } + end + + test "renders errors for invalid data", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + result = + lv + |> element("#reset_password_form") + |> render_change( + user: %{"password" => "secret12", "confirmation_password" => "secret123456"} + ) + + assert result =~ "should be at least 12 character" + assert result =~ "does not match password" + end + end + + describe "Reset Password" do + test "resets password once", %{conn: conn, token: token, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> form("#reset_password_form", + user: %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + ) + |> render_submit() + |> follow_redirect(conn, ~p"/users/log_in") + + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password reset successfully" + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "does not reset password on invalid data", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + result = + lv + |> form("#reset_password_form", + user: %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + ) + |> render_submit() + + assert result =~ "Reset Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + end + end + + describe "Reset password navigation" do + test "redirects to login page when the Log in button is clicked", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> element(~s|main a:fl-contains("Log in")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/log_in") + + assert conn.resp_body =~ "Log in" + end + + test "redirects to password reset page when the Register button is clicked", %{ + conn: conn, + token: token + } do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> element(~s|main a:fl-contains("Register")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/register") + + assert conn.resp_body =~ "Register" + end + end +end diff --git a/test/something_erlang_web/live/user_settings_live_test.exs b/test/something_erlang_web/live/user_settings_live_test.exs new file mode 100644 index 0000000..e095de8 --- /dev/null +++ b/test/something_erlang_web/live/user_settings_live_test.exs @@ -0,0 +1,210 @@ +defmodule SomethingErlangWeb.UserSettingsLiveTest do + use SomethingErlangWeb.ConnCase + + alias SomethingErlang.Accounts + import Phoenix.LiveViewTest + import SomethingErlang.AccountsFixtures + + describe "Settings page" do + test "renders settings page", %{conn: conn} do + {:ok, _lv, html} = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/settings") + + assert html =~ "Change Email" + assert html =~ "Change Password" + end + + test "redirects if user is not logged in", %{conn: conn} do + assert {:error, redirect} = live(conn, ~p"/users/settings") + + assert {:redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/log_in" + assert %{"error" => "You must log in to access this page."} = flash + end + end + + describe "update email form" do + setup %{conn: conn} do + password = valid_user_password() + user = user_fixture(%{password: password}) + %{conn: log_in_user(conn, user), user: user, password: password} + end + + test "updates the user email", %{conn: conn, password: password, user: user} do + new_email = unique_user_email() + + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#email_form", %{ + "current_password" => password, + "user" => %{"email" => new_email} + }) + |> render_submit() + + assert result =~ "A link to confirm your email" + assert Accounts.get_user_by_email(user.email) + end + + test "renders errors with invalid data (phx-change)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> element("#email_form") + |> render_change(%{ + "action" => "update_email", + "current_password" => "invalid", + "user" => %{"email" => "with spaces"} + }) + + assert result =~ "Change Email" + assert result =~ "must have the @ sign and no spaces" + end + + test "renders errors with invalid data (phx-submit)", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#email_form", %{ + "current_password" => "invalid", + "user" => %{"email" => user.email} + }) + |> render_submit() + + assert result =~ "Change Email" + assert result =~ "did not change" + assert result =~ "is not valid" + end + end + + describe "update password form" do + setup %{conn: conn} do + password = valid_user_password() + user = user_fixture(%{password: password}) + %{conn: log_in_user(conn, user), user: user, password: password} + end + + test "updates the user password", %{conn: conn, user: user, password: password} do + new_password = valid_user_password() + + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + form = + form(lv, "#password_form", %{ + "current_password" => password, + "user" => %{ + "email" => user.email, + "password" => new_password, + "password_confirmation" => new_password + } + }) + + render_submit(form) + + new_password_conn = follow_trigger_action(form, conn) + + assert redirected_to(new_password_conn) == ~p"/users/settings" + + assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) + + assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~ + "Password updated successfully" + + assert Accounts.get_user_by_email_and_password(user.email, new_password) + end + + test "renders errors with invalid data (phx-change)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> element("#password_form") + |> render_change(%{ + "current_password" => "invalid", + "user" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + + assert result =~ "Change Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + end + + test "renders errors with invalid data (phx-submit)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#password_form", %{ + "current_password" => "invalid", + "user" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + |> render_submit() + + assert result =~ "Change Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + assert result =~ "is not valid" + end + end + + describe "confirm email" do + setup %{conn: conn} do + user = user_fixture() + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{conn: log_in_user(conn, user), token: token, email: email, user: user} + end + + test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"info" => message} = flash + assert message == "Email changed successfully." + refute Accounts.get_user_by_email(user.email) + assert Accounts.get_user_by_email(email) + + # use confirm token again + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"error" => message} = flash + assert message == "Email change link is invalid or it has expired." + end + + test "does not update email with invalid token", %{conn: conn, user: user} do + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/oops") + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"error" => message} = flash + assert message == "Email change link is invalid or it has expired." + assert Accounts.get_user_by_email(user.email) + end + + test "redirects if user is not logged in", %{token: token} do + conn = build_conn() + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + assert {:redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/log_in" + assert %{"error" => message} = flash + assert message == "You must log in to access this page." + end + end +end diff --git a/test/something_erlang_web/controllers/user_auth_test.exs b/test/something_erlang_web/user_auth_test.exs similarity index 60% rename from test/something_erlang_web/controllers/user_auth_test.exs rename to test/something_erlang_web/user_auth_test.exs index 80ee8d1..817e0cb 100644 --- a/test/something_erlang_web/controllers/user_auth_test.exs +++ b/test/something_erlang_web/user_auth_test.exs @@ -1,6 +1,7 @@ defmodule SomethingErlangWeb.UserAuthTest do use SomethingErlangWeb.ConnCase, async: true + alias Phoenix.LiveView alias SomethingErlang.Accounts alias SomethingErlangWeb.UserAuth import SomethingErlang.AccountsFixtures @@ -21,7 +22,7 @@ defmodule SomethingErlangWeb.UserAuthTest do conn = UserAuth.log_in_user(conn, user) assert token = get_session(conn, :user_token) assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}" - assert redirected_to(conn) == "/" + assert redirected_to(conn) == ~p"/" assert Accounts.get_user_by_session_token(token) end @@ -59,7 +60,7 @@ defmodule SomethingErlangWeb.UserAuthTest do refute get_session(conn, :user_token) refute conn.cookies[@remember_me_cookie] assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] - assert redirected_to(conn) == "/" + assert redirected_to(conn) == ~p"/" refute Accounts.get_user_by_session_token(user_token) end @@ -78,7 +79,7 @@ defmodule SomethingErlangWeb.UserAuthTest do conn = conn |> fetch_cookies() |> UserAuth.log_out_user() refute get_session(conn, :user_token) assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] - assert redirected_to(conn) == "/" + assert redirected_to(conn) == ~p"/" end end @@ -101,8 +102,11 @@ defmodule SomethingErlangWeb.UserAuthTest do |> put_req_cookie(@remember_me_cookie, signed_token) |> UserAuth.fetch_current_user([]) - assert get_session(conn, :user_token) == user_token assert conn.assigns.current_user.id == user.id + assert get_session(conn, :user_token) == user_token + + assert get_session(conn, :live_socket_id) == + "users_sessions:#{Base.url_encode64(user_token)}" end test "does not authenticate if data is missing", %{conn: conn, user: user} do @@ -113,11 +117,106 @@ defmodule SomethingErlangWeb.UserAuthTest do end end + describe "on_mount: mount_current_user" do + test "assigns current_user based on a valid user_token ", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user.id == user.id + end + + test "assigns nil to current_user assign if there isn't a valid user_token ", %{conn: conn} do + user_token = "invalid_token" + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user == nil + end + + test "assigns nil to current_user assign if there isn't a user_token", %{conn: conn} do + session = conn |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user == nil + end + end + + describe "on_mount: ensure_authenticated" do + test "authenticates current_user based on a valid user_token ", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:ensure_authenticated, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user.id == user.id + end + + test "redirects to login page if there isn't a valid user_token ", %{conn: conn} do + user_token = "invalid_token" + session = conn |> put_session(:user_token, user_token) |> get_session() + + socket = %LiveView.Socket{ + endpoint: SomethingErlangWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket) + assert updated_socket.assigns.current_user == nil + end + + test "redirects to login page if there isn't a user_token ", %{conn: conn} do + session = conn |> get_session() + + socket = %LiveView.Socket{ + endpoint: SomethingErlangWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket) + assert updated_socket.assigns.current_user == nil + end + end + + describe "on_mount: :redirect_if_user_is_authenticated" do + test "redirects if there is an authenticated user ", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + assert {:halt, _updated_socket} = + UserAuth.on_mount( + :redirect_if_user_is_authenticated, + %{}, + session, + %LiveView.Socket{} + ) + end + + test "Don't redirect is there is no authenticated user", %{conn: conn} do + session = conn |> get_session() + + assert {:cont, _updated_socket} = + UserAuth.on_mount( + :redirect_if_user_is_authenticated, + %{}, + session, + %LiveView.Socket{} + ) + end + end + describe "redirect_if_user_is_authenticated/2" do test "redirects if user is authenticated", %{conn: conn, user: user} do conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([]) assert conn.halted - assert redirected_to(conn) == "/" + assert redirected_to(conn) == ~p"/" end test "does not redirect if user is not authenticated", %{conn: conn} do @@ -131,8 +230,11 @@ defmodule SomethingErlangWeb.UserAuthTest do test "redirects if user is not authenticated", %{conn: conn} do conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([]) assert conn.halted - assert redirected_to(conn) == Routes.user_session_path(conn, :new) - assert get_flash(conn, :error) == "You must log in to access this page." + + assert redirected_to(conn) == ~p"/users/log_in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You must log in to access this page." end test "stores the path to redirect to on GET", %{conn: conn} do diff --git a/test/something_erlang_web/views/error_view_test.exs b/test/something_erlang_web/views/error_view_test.exs deleted file mode 100644 index 1bfb4b5..0000000 --- a/test/something_erlang_web/views/error_view_test.exs +++ /dev/null @@ -1,15 +0,0 @@ -defmodule SomethingErlangWeb.ErrorViewTest do - use SomethingErlangWeb.ConnCase, async: true - - # Bring render/3 and render_to_string/3 for testing custom views - import Phoenix.View - - test "renders 404.html" do - assert render_to_string(SomethingErlangWeb.ErrorView, "404.html", []) == "Not Found" - end - - test "renders 500.html" do - assert render_to_string(SomethingErlangWeb.ErrorView, "500.html", []) == - "Internal Server Error" - end -end diff --git a/test/something_erlang_web/views/layout_view_test.exs b/test/something_erlang_web/views/layout_view_test.exs deleted file mode 100644 index 8662744..0000000 --- a/test/something_erlang_web/views/layout_view_test.exs +++ /dev/null @@ -1,8 +0,0 @@ -defmodule SomethingErlangWeb.LayoutViewTest do - use SomethingErlangWeb.ConnCase, async: true - - # When testing helpers, you may want to import Phoenix.HTML and - # use functions such as safe_to_string() to convert the helper - # result into an HTML string. - # import Phoenix.HTML -end diff --git a/test/something_erlang_web/views/page_view_test.exs b/test/something_erlang_web/views/page_view_test.exs deleted file mode 100644 index 246dc64..0000000 --- a/test/something_erlang_web/views/page_view_test.exs +++ /dev/null @@ -1,3 +0,0 @@ -defmodule SomethingErlangWeb.PageViewTest do - use SomethingErlangWeb.ConnCase, async: true -end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index fa8caae..5c19c5c 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -19,15 +19,15 @@ defmodule SomethingErlangWeb.ConnCase do using do quote do + # The default endpoint for testing + @endpoint SomethingErlangWeb.Endpoint + + use SomethingErlangWeb, :verified_routes + # Import conveniences for testing with connections import Plug.Conn import Phoenix.ConnTest import SomethingErlangWeb.ConnCase - - alias SomethingErlangWeb.Router.Helpers, as: Routes - - # The default endpoint for testing - @endpoint SomethingErlangWeb.Endpoint end end diff --git a/test/support/fixtures/forums_fixtures.ex b/test/support/fixtures/forums_fixtures.ex deleted file mode 100644 index 9076af1..0000000 --- a/test/support/fixtures/forums_fixtures.ex +++ /dev/null @@ -1,21 +0,0 @@ -defmodule SomethingErlang.ForumsFixtures do - @moduledoc """ - This module defines test helpers for creating - entities via the `SomethingErlang.Forums` context. - """ - - @doc """ - Generate a thread. - """ - def thread_fixture(attrs \\ %{}) do - {:ok, thread} = - attrs - |> Enum.into(%{ - thread_id: 42, - title: "some title" - }) - |> SomethingErlang.Forums.create_thread() - - thread - end -end