summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore8
-rw-r--r--README.md19
-rw-r--r--assets/css/app.css120
-rw-r--r--assets/css/phoenix.css101
-rw-r--r--assets/js/app.js44
-rw-r--r--assets/vendor/topbar.js157
-rw-r--r--config/config.exs52
-rw-r--r--config/dev.exs74
-rw-r--r--config/prod.exs49
-rw-r--r--config/runtime.exs89
-rw-r--r--config/test.exs33
-rw-r--r--lib/aggiedit.ex9
-rw-r--r--lib/aggiedit/accounts.ex370
-rw-r--r--lib/aggiedit/accounts/user.ex157
-rw-r--r--lib/aggiedit/accounts/user_notifier.ex79
-rw-r--r--lib/aggiedit/accounts/user_token.ex178
-rw-r--r--lib/aggiedit/application.ex36
-rw-r--r--lib/aggiedit/mailer.ex3
-rw-r--r--lib/aggiedit/repo.ex5
-rw-r--r--lib/aggiedit/rooms.ex111
-rw-r--r--lib/aggiedit/rooms/room.ex22
-rw-r--r--lib/aggiedit/utils.ex14
-rw-r--r--lib/aggiedit_web.ex110
-rw-r--r--lib/aggiedit_web/controllers/page_controller.ex7
-rw-r--r--lib/aggiedit_web/controllers/user_auth.ex170
-rw-r--r--lib/aggiedit_web/controllers/user_confirmation_controller.ex56
-rw-r--r--lib/aggiedit_web/controllers/user_registration_controller.ex30
-rw-r--r--lib/aggiedit_web/controllers/user_reset_password_controller.ex58
-rw-r--r--lib/aggiedit_web/controllers/user_session_controller.ex27
-rw-r--r--lib/aggiedit_web/controllers/user_settings_controller.ex74
-rw-r--r--lib/aggiedit_web/endpoint.ex50
-rw-r--r--lib/aggiedit_web/gettext.ex24
-rw-r--r--lib/aggiedit_web/router.ex92
-rw-r--r--lib/aggiedit_web/telemetry.ex71
-rw-r--r--lib/aggiedit_web/templates/layout/_user_menu.html.heex10
-rw-r--r--lib/aggiedit_web/templates/layout/app.html.heex5
-rw-r--r--lib/aggiedit_web/templates/layout/live.html.heex11
-rw-r--r--lib/aggiedit_web/templates/layout/root.html.heex31
-rw-r--r--lib/aggiedit_web/templates/page/index.html.heex41
-rw-r--r--lib/aggiedit_web/templates/user_confirmation/edit.html.heex12
-rw-r--r--lib/aggiedit_web/templates/user_confirmation/new.html.heex15
-rw-r--r--lib/aggiedit_web/templates/user_registration/new.html.heex30
-rw-r--r--lib/aggiedit_web/templates/user_reset_password/edit.html.heex26
-rw-r--r--lib/aggiedit_web/templates/user_reset_password/new.html.heex15
-rw-r--r--lib/aggiedit_web/templates/user_session/new.html.heex27
-rw-r--r--lib/aggiedit_web/templates/user_settings/edit.html.heex53
-rw-r--r--lib/aggiedit_web/views/error_helpers.ex47
-rw-r--r--lib/aggiedit_web/views/error_view.ex16
-rw-r--r--lib/aggiedit_web/views/layout_view.ex7
-rw-r--r--lib/aggiedit_web/views/page_view.ex3
-rw-r--r--lib/aggiedit_web/views/user_confirmation_view.ex3
-rw-r--r--lib/aggiedit_web/views/user_registration_view.ex3
-rw-r--r--lib/aggiedit_web/views/user_reset_password_view.ex3
-rw-r--r--lib/aggiedit_web/views/user_session_view.ex3
-rw-r--r--lib/aggiedit_web/views/user_settings_view.ex3
-rw-r--r--mix.exs72
-rw-r--r--mix.lock39
-rw-r--r--priv/gettext/en/LC_MESSAGES/errors.po112
-rw-r--r--priv/gettext/errors.pot95
-rw-r--r--priv/repo/migrations/.formatter.exs4
-rw-r--r--priv/repo/migrations/20220405070421_create_rooms.exs13
-rw-r--r--priv/repo/migrations/20220405071636_create_users_auth_tables.exs30
-rw-r--r--priv/repo/seeds.exs11
-rw-r--r--proposal.md30
-rw-r--r--test/aggiedit/accounts_test.exs508
-rw-r--r--test/aggiedit/rooms_test.exs59
-rw-r--r--test/aggiedit_web/controllers/page_controller_test.exs8
-rw-r--r--test/aggiedit_web/controllers/user_auth_test.exs170
-rw-r--r--test/aggiedit_web/controllers/user_confirmation_controller_test.exs105
-rw-r--r--test/aggiedit_web/controllers/user_registration_controller_test.exs54
-rw-r--r--test/aggiedit_web/controllers/user_reset_password_controller_test.exs113
-rw-r--r--test/aggiedit_web/controllers/user_session_controller_test.exs98
-rw-r--r--test/aggiedit_web/controllers/user_settings_controller_test.exs129
-rw-r--r--test/aggiedit_web/views/error_view_test.exs14
-rw-r--r--test/aggiedit_web/views/layout_view_test.exs8
-rw-r--r--test/aggiedit_web/views/page_view_test.exs3
-rw-r--r--test/support/channel_case.ex36
-rw-r--r--test/support/conn_case.ex65
-rw-r--r--test/support/data_case.ex51
-rw-r--r--test/support/fixtures/accounts_fixtures.ex31
-rw-r--r--test/support/fixtures/rooms_fixtures.ex20
-rw-r--r--test/test_helper.exs2
82 files changed, 4743 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a5ac23f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+/.env
+/_build/
+/doc/
+erl_crash.dump
+*.ez
+/assets/node_modules/
+/priv/static/
+/deps
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5bf2b22
--- /dev/null
+++ b/README.md
@@ -0,0 +1,19 @@
+# Aggiedit
+
+To start your Phoenix server:
+
+ * Install dependencies with `mix deps.get`
+ * Create and migrate your database with `mix ecto.setup`
+ * 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
new file mode 100644
index 0000000..19c2e51
--- /dev/null
+++ b/assets/css/app.css
@@ -0,0 +1,120 @@
+/* This file is for your main application CSS */
+@import "./phoenix.css";
+
+/* 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
new file mode 100644
index 0000000..0d59050
--- /dev/null
+++ b/assets/css/phoenix.css
@@ -0,0 +1,101 @@
+/* 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,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 8" width="30"><path fill="%23d1d1d1" d="M0,0l6,8l6-8"/></svg>') center right no-repeat;padding-right:3.0rem}select:focus{background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 8" width="30"><path fill="%230069d9" d="M0,0l6,8l6-8"/></svg>')}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
new file mode 100644
index 0000000..3d4ea2a
--- /dev/null
+++ b/assets/js/app.js
@@ -0,0 +1,44 @@
+// 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).
+import "../css/app.css"
+
+// 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"
+
+// You can include dependencies in two ways.
+//
+// The simplest option is to put them in assets/vendor and
+// import them using relative paths:
+//
+// import "../vendor/some-package.js"
+//
+// Alternatively, you can `npm install some-package --prefix assets` and import
+// them using a path starting with the package name:
+//
+// import "some-package"
+//
+
+// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
+import "phoenix_html"
+// Establish Phoenix Socket and LiveView configuration.
+import {Socket} from "phoenix"
+import {LiveSocket} from "phoenix_live_view"
+import topbar from "../vendor/topbar"
+
+let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
+let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
+
+// 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-stop", info => topbar.hide())
+
+// connect if there are any LiveViews on the page
+liveSocket.connect()
+
+// expose liveSocket on window for web console debug logs and latency simulation:
+// >> liveSocket.enableDebug()
+// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
+// >> liveSocket.disableLatencySim()
+window.liveSocket = liveSocket \ No newline at end of file
diff --git a/assets/vendor/topbar.js b/assets/vendor/topbar.js
new file mode 100644
index 0000000..1f62209
--- /dev/null
+++ b/assets/vendor/topbar.js
@@ -0,0 +1,157 @@
+/**
+ * @license MIT
+ * topbar 1.0.0, 2021-01-06
+ * https://buunguyen.github.io/topbar
+ * Copyright (c) 2021 Buu Nguyen
+ */
+(function (window, document) {
+ "use strict";
+
+ // https://gist.github.com/paulirish/1579671
+ (function () {
+ var lastTime = 0;
+ var vendors = ["ms", "moz", "webkit", "o"];
+ for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
+ window.requestAnimationFrame =
+ window[vendors[x] + "RequestAnimationFrame"];
+ window.cancelAnimationFrame =
+ window[vendors[x] + "CancelAnimationFrame"] ||
+ window[vendors[x] + "CancelRequestAnimationFrame"];
+ }
+ if (!window.requestAnimationFrame)
+ window.requestAnimationFrame = function (callback, element) {
+ var currTime = new Date().getTime();
+ var timeToCall = Math.max(0, 16 - (currTime - lastTime));
+ var id = window.setTimeout(function () {
+ callback(currTime + timeToCall);
+ }, timeToCall);
+ lastTime = currTime + timeToCall;
+ return id;
+ };
+ if (!window.cancelAnimationFrame)
+ window.cancelAnimationFrame = function (id) {
+ clearTimeout(id);
+ };
+ })();
+
+ var canvas,
+ progressTimerId,
+ fadeTimerId,
+ currentProgress,
+ showing,
+ addEvent = function (elem, type, handler) {
+ if (elem.addEventListener) elem.addEventListener(type, handler, false);
+ else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
+ else elem["on" + type] = handler;
+ },
+ options = {
+ autoRun: true,
+ barThickness: 3,
+ barColors: {
+ 0: "rgba(26, 188, 156, .9)",
+ ".25": "rgba(52, 152, 219, .9)",
+ ".50": "rgba(241, 196, 15, .9)",
+ ".75": "rgba(230, 126, 34, .9)",
+ "1.0": "rgba(211, 84, 0, .9)",
+ },
+ shadowBlur: 10,
+ shadowColor: "rgba(0, 0, 0, .6)",
+ className: null,
+ },
+ repaint = function () {
+ canvas.width = window.innerWidth;
+ canvas.height = options.barThickness * 5; // need space for shadow
+
+ var ctx = canvas.getContext("2d");
+ ctx.shadowBlur = options.shadowBlur;
+ ctx.shadowColor = options.shadowColor;
+
+ var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
+ for (var stop in options.barColors)
+ lineGradient.addColorStop(stop, options.barColors[stop]);
+ ctx.lineWidth = options.barThickness;
+ ctx.beginPath();
+ ctx.moveTo(0, options.barThickness / 2);
+ ctx.lineTo(
+ Math.ceil(currentProgress * canvas.width),
+ options.barThickness / 2
+ );
+ ctx.strokeStyle = lineGradient;
+ ctx.stroke();
+ },
+ createCanvas = function () {
+ canvas = document.createElement("canvas");
+ var style = canvas.style;
+ style.position = "fixed";
+ style.top = style.left = style.right = style.margin = style.padding = 0;
+ style.zIndex = 100001;
+ style.display = "none";
+ if (options.className) canvas.classList.add(options.className);
+ document.body.appendChild(canvas);
+ addEvent(window, "resize", repaint);
+ },
+ topbar = {
+ config: function (opts) {
+ for (var key in opts)
+ if (options.hasOwnProperty(key)) options[key] = opts[key];
+ },
+ show: function () {
+ if (showing) return;
+ showing = true;
+ if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
+ if (!canvas) createCanvas();
+ canvas.style.opacity = 1;
+ canvas.style.display = "block";
+ topbar.progress(0);
+ if (options.autoRun) {
+ (function loop() {
+ progressTimerId = window.requestAnimationFrame(loop);
+ topbar.progress(
+ "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
+ );
+ })();
+ }
+ },
+ progress: function (to) {
+ if (typeof to === "undefined") return currentProgress;
+ if (typeof to === "string") {
+ to =
+ (to.indexOf("+") >= 0 || to.indexOf("-") >= 0
+ ? currentProgress
+ : 0) + parseFloat(to);
+ }
+ currentProgress = to > 1 ? 1 : to;
+ repaint();
+ return currentProgress;
+ },
+ hide: function () {
+ if (!showing) return;
+ showing = false;
+ if (progressTimerId != null) {
+ window.cancelAnimationFrame(progressTimerId);
+ progressTimerId = null;
+ }
+ (function loop() {
+ if (topbar.progress("+.1") >= 1) {
+ canvas.style.opacity -= 0.05;
+ if (canvas.style.opacity <= 0.05) {
+ canvas.style.display = "none";
+ fadeTimerId = null;
+ return;
+ }
+ }
+ fadeTimerId = window.requestAnimationFrame(loop);
+ })();
+ },
+ };
+
+ if (typeof module === "object" && typeof module.exports === "object") {
+ module.exports = topbar;
+ } else if (typeof define === "function" && define.amd) {
+ define(function () {
+ return topbar;
+ });
+ } else {
+ this.topbar = topbar;
+ }
+}.call(this, window, document));
diff --git a/config/config.exs b/config/config.exs
new file mode 100644
index 0000000..0ffcbbd
--- /dev/null
+++ b/config/config.exs
@@ -0,0 +1,52 @@
+# This file is responsible for configuring your application
+# and its dependencies with the aid of the Config module.
+#
+# This configuration file is loaded before any dependency and
+# is restricted to this project.
+
+# General application configuration
+import Config
+
+config :aggiedit,
+ ecto_repos: [Aggiedit.Repo]
+
+# Configures the endpoint
+config :aggiedit, AggieditWeb.Endpoint,
+ url: [host: "localhost"],
+ render_errors: [view: AggieditWeb.ErrorView, accepts: ~w(html json), layout: false],
+ pubsub_server: Aggiedit.PubSub,
+ live_view: [signing_salt: "IXjGfFT1"]
+
+# Configures the mailer
+#
+# By default it uses the "Local" adapter which stores the emails
+# locally. You can see the emails in your browser, at "/dev/mailbox".
+#
+# For production it's recommended to configure a different adapter
+# at the `config/runtime.exs`.
+config :aggiedit, Aggiedit.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.0",
+ default: [
+ args:
+ ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
+ cd: Path.expand("../assets", __DIR__),
+ env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
+ ]
+
+# Configures Elixir's Logger
+config :logger, :console,
+ format: "$time $metadata[$level] $message\n",
+ metadata: [:request_id]
+
+# Use Jason for JSON parsing in Phoenix
+config :phoenix, :json_library, Jason
+
+# Import environment specific config. This must remain at the bottom
+# of this file so it overrides the configuration defined above.
+import_config "#{config_env()}.exs"
diff --git a/config/dev.exs b/config/dev.exs
new file mode 100644
index 0000000..0e233c8
--- /dev/null
+++ b/config/dev.exs
@@ -0,0 +1,74 @@
+import Config
+
+# Configure your database
+config :aggiedit, Aggiedit.Repo,
+ username: "postgres",
+ password: "postgres",
+ hostname: "localhost",
+ database: "aggiedit_dev",
+ show_sensitive_data_on_connection_error: true,
+ pool_size: 10
+
+# For development, we disable any cache and enable
+# debugging and code reloading.
+#
+# The watchers configuration can be used to run external
+# watchers to your application. For example, we use it
+# with esbuild to bundle .js and .css sources.
+config :aggiedit, AggieditWeb.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: {127, 0, 0, 1}, port: 4000],
+ check_origin: false,
+ code_reloader: true,
+ debug_errors: true,
+ secret_key_base: "8Z7OhslV9OVCMbhy39QKkKpzWRHZUrB82y0RPkn/+Tuz6IYoL9wbsMuNv3yzrKEg",
+ watchers: [
+ # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args)
+ esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}
+ ]
+
+# ## SSL Support
+#
+# In order to use HTTPS in development, a self-signed
+# certificate can be generated by running the following
+# Mix task:
+#
+# 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:
+#
+# https: [
+# port: 4001,
+# cipher_suite: :strong,
+# keyfile: "priv/cert/selfsigned_key.pem",
+# certfile: "priv/cert/selfsigned.pem"
+# ],
+#
+# If desired, both `http:` and `https:` keys can be
+# configured to run both http and https servers on
+# different ports.
+
+# Watch static and templates for browser reloading.
+config :aggiedit, AggieditWeb.Endpoint,
+ live_reload: [
+ patterns: [
+ ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
+ ~r"priv/gettext/.*(po)$",
+ ~r"lib/aggiedit_web/(live|views)/.*(ex)$",
+ ~r"lib/aggiedit_web/templates/.*(eex)$"
+ ]
+ ]
+
+# Do not include metadata nor timestamps in development logs
+config :logger, :console, format: "[$level] $message\n"
+
+# Set a higher stacktrace during development. Avoid configuring such
+# in production as building large stacktraces may be expensive.
+config :phoenix, :stacktrace_depth, 20
+
+# Initialize plugs at runtime for faster development compilation
+config :phoenix, :plug_init_mode, :runtime
diff --git a/config/prod.exs b/config/prod.exs
new file mode 100644
index 0000000..cccf466
--- /dev/null
+++ b/config/prod.exs
@@ -0,0 +1,49 @@
+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 :aggiedit, AggieditWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
+
+# 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 :aggiedit, AggieditWeb.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 :aggiedit, AggieditWeb.Endpoint,
+# force_ssl: [hsts: true]
+#
+# Check `Plug.SSL` for all available options in `force_ssl`.
diff --git a/config/runtime.exs b/config/runtime.exs
new file mode 100644
index 0000000..50fa43f
--- /dev/null
+++ b/config/runtime.exs
@@ -0,0 +1,89 @@
+import Config
+
+# config/runtime.exs is executed for all environments, including
+# during releases. It is executed after compilation and before the
+# system starts, so it is typically used to load production configuration
+# and secrets from environment variables or elsewhere. Do not define
+# any compile-time configuration in here, as it won't be applied.
+# The block below contains prod specific runtime configuration.
+
+# Start the phoenix server if environment is set and running in a release
+if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do
+ config :aggiedit, AggieditWeb.Endpoint, server: true
+end
+
+if config_env() == :prod do
+ config :aggiedit, Aggiedit.Mailer,
+ adapter: Swoosh.Adapters.Sendgrid,
+ api_key: System.get_env("SENDGRID_KEY")
+
+ database_url =
+ System.get_env("DATABASE_URL") ||
+ raise """
+ environment variable DATABASE_URL is missing.
+ For example: ecto://USER:PASS@HOST/DATABASE
+ """
+
+ maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: []
+
+ config :aggiedit, Aggiedit.Repo,
+ # ssl: true,
+ url: database_url,
+ pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
+ socket_options: maybe_ipv6
+
+ # The secret key base is used to sign/encrypt cookies and other secrets.
+ # A default value is used in config/dev.exs and config/test.exs but you
+ # want to use a different value for prod and you most likely don't want
+ # to check this value into version control, so we use an environment
+ # variable instead.
+ secret_key_base =
+ System.get_env("SECRET_KEY_BASE") ||
+ raise """
+ environment variable SECRET_KEY_BASE is missing.
+ You can generate one by calling: mix phx.gen.secret
+ """
+
+ host = System.get_env("PHX_HOST") || "example.com"
+ port = String.to_integer(System.get_env("PORT") || "4000")
+
+ config :aggiedit, AggieditWeb.Endpoint,
+ url: [host: host, port: 443],
+ http: [
+ # Enable IPv6 and bind on all interfaces.
+ # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
+ # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
+ # for details about using IPv6 vs IPv4 and loopback vs public addresses.
+ ip: {0, 0, 0, 0, 0, 0, 0, 0},
+ port: port
+ ],
+ secret_key_base: secret_key_base
+
+ # ## Using releases
+ #
+ # If you are doing OTP releases, you need to instruct Phoenix
+ # to start each relevant endpoint:
+ #
+ # config :aggiedit, AggieditWeb.Endpoint, server: true
+ #
+ # Then you can assemble a release by calling `mix release`.
+ # See `mix help release` for more information.
+
+ # ## Configuring the mailer
+ #
+ # In production you need to configure the mailer to use a different adapter.
+ # Also, you may need to configure the Swoosh API client of your choice if you
+ # are not using SMTP. Here is an example of the configuration:
+ #
+ # config :aggiedit, Aggiedit.Mailer,
+ # adapter: Swoosh.Adapters.Mailgun,
+ # api_key: System.get_env("MAILGUN_API_KEY"),
+ # domain: System.get_env("MAILGUN_DOMAIN")
+ #
+ # For this example you need include a HTTP client required by Swoosh API client.
+ # Swoosh supports Hackney and Finch out of the box:
+ #
+ # config :swoosh, :api_client, Swoosh.ApiClient.Hackney
+ #
+ # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
+end
diff --git a/config/test.exs b/config/test.exs
new file mode 100644
index 0000000..1f866f3
--- /dev/null
+++ b/config/test.exs
@@ -0,0 +1,33 @@
+import Config
+
+# Only in tests, remove the complexity from the password hashing algorithm
+config :bcrypt_elixir, :log_rounds, 1
+
+# Configure your database
+#
+# The MIX_TEST_PARTITION environment variable can be used
+# to provide built-in test partitioning in CI environment.
+# Run `mix help test` for more information.
+config :aggiedit, Aggiedit.Repo,
+ username: "postgres",
+ password: "postgres",
+ hostname: "localhost",
+ database: "aggiedit_test#{System.get_env("MIX_TEST_PARTITION")}",
+ pool: Ecto.Adapters.SQL.Sandbox,
+ pool_size: 10
+
+# We don't run a server during test. If one is required,
+# you can enable the server option below.
+config :aggiedit, AggieditWeb.Endpoint,
+ http: [ip: {127, 0, 0, 1}, port: 4002],
+ secret_key_base: "laWbfSdi4Bv7NbMmQkwsU3ZnTKW/10I3SsErPMvAsOMcQqx+P2IdZaZfhJzlIQ8U",
+ server: false
+
+# In test we don't send emails.
+config :aggiedit, Aggiedit.Mailer, adapter: Swoosh.Adapters.Test
+
+# Print only warnings and errors during test
+config :logger, level: :warn
+
+# Initialize plugs at runtime for faster test compilation
+config :phoenix, :plug_init_mode, :runtime
diff --git a/lib/aggiedit.ex b/lib/aggiedit.ex
new file mode 100644
index 0000000..d9da6de
--- /dev/null
+++ b/lib/aggiedit.ex
@@ -0,0 +1,9 @@
+defmodule Aggiedit do
+ @moduledoc """
+ Aggiedit keeps the contexts that define your domain
+ and business logic.
+
+ Contexts are also responsible for managing your data, regardless
+ if it comes from the database, an external API or others.
+ """
+end
diff --git a/lib/aggiedit/accounts.ex b/lib/aggiedit/accounts.ex
new file mode 100644
index 0000000..9d0bd18
--- /dev/null
+++ b/lib/aggiedit/accounts.ex
@@ -0,0 +1,370 @@
+defmodule Aggiedit.Accounts do
+ @moduledoc """
+ The Accounts context.
+ """
+
+ import Ecto.Query, warn: false
+ alias Aggiedit.Repo
+
+ alias Aggiedit.Accounts.{User, UserToken, UserNotifier}
+ alias Aggiedit.Rooms
+ alias Aggiedit.Rooms.Room
+
+ ## Database getters
+
+ @doc """
+ Gets a user by email.
+
+ ## Examples
+
+ iex> get_user_by_email("foo@example.com")
+ %User{}
+
+ iex> get_user_by_email("unknown@example.com")
+ nil
+
+ """
+ def get_user_by_email(email) when is_binary(email) do
+ Repo.get_by(User, email: email)
+ end
+
+ @doc """
+ Gets a user by email and password.
+
+ ## Examples
+
+ iex> get_user_by_email_and_password("foo@example.com", "correct_password")
+ %User{}
+
+ iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
+ nil
+
+ """
+ def get_user_by_email_and_password(email, password)
+ when is_binary(email) and is_binary(password) do
+ user = Repo.get_by(User, email: email)
+ if User.valid_password?(user, password), do: user
+ end
+
+ @doc """
+ Gets a single user.
+
+ Raises `Ecto.NoResultsError` if the User does not exist.
+
+ ## Examples
+
+ iex> get_user!(123)
+ %User{}
+
+ iex> get_user!(456)
+ ** (Ecto.NoResultsError)
+
+ """
+ def get_user!(id), do: Repo.get!(User, id)
+
+ ## User registration
+
+ @doc """
+ Registers a user.
+
+ ## Examples
+
+ iex> register_user(%{field: value})
+ {:ok, %User{}}
+
+ iex> register_user(%{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def register_user(attrs) do
+ %User{}
+ |> User.registration_changeset(attrs)
+ |> Repo.insert()
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for tracking user changes.
+
+ ## Examples
+
+ iex> change_user_registration(user)
+ %Ecto.Changeset{data: %User{}}
+
+ """
+ def change_user_registration(%User{} = user, attrs \\ %{}) do
+ User.registration_changeset(user, attrs, hash_password: false)
+ end
+
+ ## Settings
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for changing the user email.
+
+ ## Examples
+
+ iex> change_user_email(user)
+ %Ecto.Changeset{data: %User{}}
+
+ """
+ def change_user_email(user, attrs \\ %{}) do
+ User.email_changeset(user, attrs)
+ end
+
+ @doc """
+ Emulates that the email will change without actually changing
+ it in the database.
+
+ ## Examples
+
+ iex> apply_user_email(user, "valid password", %{email: ...})
+ {:ok, %User{}}
+
+ iex> apply_user_email(user, "invalid password", %{email: ...})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def apply_user_email(user, password, attrs) do
+ user
+ |> User.email_changeset(attrs)
+ |> User.validate_current_password(password)
+ |> Ecto.Changeset.apply_action(:update)
+ end
+
+ @doc """
+ Updates the user email using the given token.
+
+ If the token matches, the user email is updated and the token is deleted.
+ The confirmed_at date is also updated to the current time.
+ """
+ def update_user_email(user, token) do
+ context = "change:#{user.email}"
+
+ with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
+ %UserToken{sent_to: email} <- Repo.one(query),
+ {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)),
+ {:ok, user} <- set_user_room(user) do
+ :ok
+ else
+ _ -> :error
+ end
+ end
+
+ def set_user_room(user) do
+ # TODO: Make posts by user in previous room inaccessible by user
+ with domain <- Aggiedit.Utils.get_email_domain(user.email),
+ {:ok, room} <- Rooms.create_or_find_room_with_domain(domain) do
+ user
+ |> Repo.preload(:room)
+ |> User.room_changeset(room)
+ |> Repo.update()
+ else
+ _ -> {:error, "Could not find or create room with your email"}
+ end
+ end
+
+ defp user_email_multi(user, email, context) do
+ changeset =
+ user
+ |> User.email_changeset(%{email: email})
+ |> User.confirm_changeset()
+
+ Ecto.Multi.new()
+ |> Ecto.Multi.update(:user, changeset)
+ |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context]))
+ end
+
+ @doc """
+ 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))
+ {:ok, %{to: ..., body: ...}}
+
+ """
+ def deliver_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}")
+
+ Repo.insert!(user_token)
+ UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for changing the user password.
+
+ ## Examples
+
+ iex> change_user_password(user)
+ %Ecto.Changeset{data: %User{}}
+
+ """
+ def change_user_password(user, attrs \\ %{}) do
+ User.password_changeset(user, attrs, hash_password: false)
+ end
+
+ @doc """
+ Updates the user password.
+
+ ## Examples
+
+ iex> update_user_password(user, "valid password", %{password: ...})
+ {:ok, %User{}}
+
+ iex> update_user_password(user, "invalid password", %{password: ...})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def update_user_password(user, password, attrs) do
+ changeset =
+ user
+ |> User.password_changeset(attrs)
+ |> User.validate_current_password(password)
+
+ Ecto.Multi.new()
+ |> Ecto.Multi.update(:user, changeset)
+ |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
+ |> Repo.transaction()
+ |> case do
+ {:ok, %{user: user}} -> {:ok, user}
+ {:error, :user, changeset, _} -> {:error, changeset}
+ end
+ end
+
+ ## Session
+
+ @doc """
+ Generates a session token.
+ """
+ def generate_user_session_token(user) do
+ {token, user_token} = UserToken.build_session_token(user)
+ Repo.insert!(user_token)
+ token
+ end
+
+ @doc """
+ Gets the user with the given signed token.
+ """
+ def get_user_by_session_token(token) do
+ {:ok, query} = UserToken.verify_session_token_query(token)
+ Repo.one(query)
+ end
+
+ @doc """
+ Deletes the signed token with the given context.
+ """
+ def delete_session_token(token) do
+ Repo.delete_all(UserToken.token_and_context_query(token, "session"))
+ :ok
+ end
+
+ ## Confirmation
+
+ @doc """
+ Delivers the confirmation email instructions to the given user.
+
+ ## Examples
+
+ iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :edit, &1))
+ {:ok, %{to: ..., body: ...}}
+
+ iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :edit, &1))
+ {:error, :already_confirmed}
+
+ """
+ def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun)
+ when is_function(confirmation_url_fun, 1) do
+ if user.confirmed_at do
+ {:error, :already_confirmed}
+ else
+ {encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
+ Repo.insert!(user_token)
+ UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token))
+ end
+ end
+
+ @doc """
+ Confirms a user by the given token.
+
+ If the token matches, the user account is marked as confirmed
+ and the token is deleted.
+ """
+ def confirm_user(token) do
+ with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"),
+ %User{} = user <- Repo.one(query),
+ {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)),
+ {:ok, user} <- set_user_room(user) do
+ {:ok, user}
+ else
+ _ -> :error
+ end
+ end
+
+ defp confirm_user_multi(user) do
+ Ecto.Multi.new()
+ |> Ecto.Multi.update(:user, User.confirm_changeset(user))
+ |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"]))
+ end
+
+ ## Reset password
+
+ @doc """
+ 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))
+ {:ok, %{to: ..., body: ...}}
+
+ """
+ def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
+ when is_function(reset_password_url_fun, 1) do
+ {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
+ Repo.insert!(user_token)
+ UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
+ end
+
+ @doc """
+ Gets the user by reset password token.
+
+ ## Examples
+
+ iex> get_user_by_reset_password_token("validtoken")
+ %User{}
+
+ iex> get_user_by_reset_password_token("invalidtoken")
+ nil
+
+ """
+ def get_user_by_reset_password_token(token) do
+ with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"),
+ %User{} = user <- Repo.one(query) do
+ user
+ else
+ _ -> nil
+ end
+ end
+
+ @doc """
+ Resets the user password.
+
+ ## Examples
+
+ iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
+ {:ok, %User{}}
+
+ iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def reset_user_password(user, attrs) do
+ Ecto.Multi.new()
+ |> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
+ |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
+ |> Repo.transaction()
+ |> case do
+ {:ok, %{user: user}} -> {:ok, user}
+ {:error, :user, changeset, _} -> {:error, changeset}
+ end
+ end
+end
diff --git a/lib/aggiedit/accounts/user.ex b/lib/aggiedit/accounts/user.ex
new file mode 100644
index 0000000..aeb37ed
--- /dev/null
+++ b/lib/aggiedit/accounts/user.ex
@@ -0,0 +1,157 @@
+defmodule Aggiedit.Accounts.User do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ alias Aggiedit.Rooms.Room
+
+ schema "users" do
+ field :email, :string
+ field :username, :string
+ field :password, :string, virtual: true, redact: true
+ field :hashed_password, :string, redact: true
+ field :confirmed_at, :naive_datetime
+ field :role, Ecto.Enum, values: [:user, :admin], default: :user
+
+ belongs_to :room, Room, on_replace: :update
+
+ timestamps()
+ end
+
+ @doc """
+ A user changeset for registration.
+
+ It is important to validate the length of both email and password.
+ Otherwise databases may truncate the email without warnings, which
+ could lead to unpredictable or insecure behaviour. Long passwords may
+ also be very expensive to hash for certain algorithms.
+
+ ## Options
+
+ * `:hash_password` - Hashes the password so it can be stored securely
+ in the database and ensures the password field is cleared to prevent
+ leaks in the logs. If password hashing is not needed and clearing the
+ 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`.
+ """
+ def registration_changeset(user, attrs, opts \\ []) do
+ user
+ |> cast(attrs, [:username, :email, :password])
+ |> validate_username()
+ |> validate_email()
+ |> validate_password(opts)
+ end
+
+ defp validate_username(changeset) do
+ changeset
+ |> validate_required([:username])
+ |> validate_format(:username, ~r/^[a-z0-9_\-]*$/, message: "only lowercase letters, numbers, underscores, and hyphens allowed")
+ |> unique_constraint(:username)
+ end
+
+ defp validate_email(changeset) do
+ changeset
+ |> validate_required([:email])
+ |> validate_format(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/, message: "must have the @ sign, no spaces, and a domain")
+ |> validate_length(:email, max: 160)
+ |> unsafe_validate_unique(:email, Aggiedit.Repo)
+ |> unique_constraint(:email)
+ end
+
+ defp validate_password(changeset, opts) do
+ changeset
+ |> validate_required([:password])
+ |> validate_length(:password, min: 8, max: 72)
+ # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
+ # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
+ # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
+ |> maybe_hash_password(opts)
+ end
+
+ defp maybe_hash_password(changeset, opts) do
+ hash_password? = Keyword.get(opts, :hash_password, true)
+ password = get_change(changeset, :password)
+
+ if hash_password? && password && changeset.valid? do
+ changeset
+ # If using Bcrypt, then further validate it is at most 72 bytes long
+ |> validate_length(:password, max: 72, count: :bytes)
+ |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
+ |> delete_change(:password)
+ 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
+ user
+ |> cast(attrs, [:email])
+ |> validate_email()
+ |> case do
+ %{changes: %{email: _}} = changeset -> changeset
+ %{} = changeset -> add_error(changeset, :email, "did not change")
+ end
+ end
+
+ @doc """
+ A user changeset for changing the password.
+
+ ## Options
+
+ * `:hash_password` - Hashes the password so it can be stored securely
+ in the database and ensures the password field is cleared to prevent
+ leaks in the logs. If password hashing is not needed and clearing the
+ 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`.
+ """
+ def password_changeset(user, attrs, opts \\ []) do
+ user
+ |> cast(attrs, [:password])
+ |> validate_confirmation(:password, message: "does not match password")
+ |> validate_password(opts)
+ end
+
+ @doc """
+ Confirms the account by setting `confirmed_at`.
+ """
+ def confirm_changeset(user) do
+ now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
+ change(user, confirmed_at: now)
+ end
+
+ def room_changeset(user, room) do
+ change(user)
+ |> cast(%{:room_id => room.id}, [:room_id])
+ end
+
+ @doc """
+ Verifies the password.
+
+ If there is no user or the user doesn't have a password, we call
+ `Bcrypt.no_user_verify/0` to avoid timing attacks.
+ """
+ def valid_password?(%Aggiedit.Accounts.User{hashed_password: hashed_password}, password)
+ when is_binary(hashed_password) and byte_size(password) > 0 do
+ Bcrypt.verify_pass(password, hashed_password)
+ end
+
+ def valid_password?(_, _) do
+ Bcrypt.no_user_verify()
+ false
+ end
+
+ @doc """
+ Validates the current password otherwise adds an error to the changeset.
+ """
+ def validate_current_password(changeset, password) do
+ if valid_password?(changeset.data, password) do
+ changeset
+ else
+ add_error(changeset, :current_password, "is not valid")
+ end
+ end
+end
diff --git a/lib/aggiedit/accounts/user_notifier.ex b/lib/aggiedit/accounts/user_notifier.ex
new file mode 100644
index 0000000..9230716
--- /dev/null
+++ b/lib/aggiedit/accounts/user_notifier.ex
@@ -0,0 +1,79 @@
+defmodule Aggiedit.Accounts.UserNotifier do
+ import Swoosh.Email
+
+ alias Aggiedit.Mailer
+
+ # Delivers the email using the application mailer.
+ defp deliver(recipient, subject, body) do
+ email =
+ new()
+ |> to(recipient)
+ |> from({"Aggiedit", "info@simponic.xyz"})
+ |> subject(subject)
+ |> text_body(body)
+
+ with {:ok, _metadata} <- Mailer.deliver(email) do
+ {:ok, email}
+ end
+ end
+
+ @doc """
+ Deliver instructions to confirm account.
+ """
+ def deliver_confirmation_instructions(user, url) do
+ deliver(user.email, "Confirmation instructions", """
+
+ ==============================
+
+ Hi #{user.email},
+
+ You can confirm your account by visiting the URL below:
+
+ #{url}
+
+ If you didn't create an account with us, please ignore this.
+
+ ==============================
+ """)
+ end
+
+ @doc """
+ Deliver instructions to reset a user password.
+ """
+ def deliver_reset_password_instructions(user, url) do
+ deliver(user.email, "Reset password instructions", """
+
+ ==============================
+
+ Hi #{user.email},
+
+ You can reset your password by visiting the URL below:
+
+ #{url}
+
+ If you didn't request this change, please ignore this.
+
+ ==============================
+ """)
+ end
+
+ @doc """
+ Deliver instructions to update a user email.
+ """
+ def deliver_update_email_instructions(user, url) do
+ deliver(user.email, "Update email instructions", """
+
+ ==============================
+
+ Hi #{user.email},
+
+ You can change your email by visiting the URL below:
+
+ #{url}
+
+ If you didn't request this change, please ignore this.
+
+ ==============================
+ """)
+ end
+end
diff --git a/lib/aggiedit/accounts/user_token.ex b/lib/aggiedit/accounts/user_token.ex
new file mode 100644
index 0000000..7d9ad16
--- /dev/null
+++ b/lib/aggiedit/accounts/user_token.ex
@@ -0,0 +1,178 @@
+defmodule Aggiedit.Accounts.UserToken do
+ use Ecto.Schema
+ import Ecto.Query
+
+ @hash_algorithm :sha256
+ @rand_size 32
+
+ # It is very important to keep the reset password token expiry short,
+ # since someone with access to the email may take over the account.
+ @reset_password_validity_in_days 1
+ @confirm_validity_in_days 7
+ @change_email_validity_in_days 7
+ @session_validity_in_days 60
+
+ schema "users_tokens" do
+ field :token, :binary
+ field :context, :string
+ field :sent_to, :string
+ belongs_to :user, Aggiedit.Accounts.User
+
+ timestamps(updated_at: false)
+ end
+
+ @doc """
+ Generates a token that will be stored in a signed place,
+ such as session or cookie. As they are signed, those
+ tokens do not need to be hashed.
+
+ The reason why we store session tokens in the database, even
+ though Phoenix already provides a session cookie, is because
+ Phoenix' default session cookies are not persisted, they are
+ simply signed and potentially encrypted. This means they are
+ valid indefinitely, unless you change the signing/encryption
+ salt.
+
+ Therefore, storing them allows individual user
+ sessions to be expired. The token system can also be extended
+ to store additional data, such as the device used for logging in.
+ You could then use this information to display all valid sessions
+ and devices in the UI and allow users to explicitly expire any
+ session they deem invalid.
+ """
+ def build_session_token(user) do
+ token = :crypto.strong_rand_bytes(@rand_size)
+ {token, %Aggiedit.Accounts.UserToken{token: token, context: "session", user_id: user.id}}
+ end
+
+ @doc """
+ Checks if the token is valid and returns its underlying lookup query.
+
+ The query returns the user found by the token, if any.
+
+ The token is valid if it matches the value in the database and it has
+ not expired (after @session_validity_in_days).
+ """
+ def verify_session_token_query(token) do
+ query =
+ from token in token_and_context_query(token, "session"),
+ join: user in assoc(token, :user),
+ where: token.inserted_at > ago(@session_validity_in_days, "day"),
+ select: user
+
+ {:ok, query}
+ end
+
+ @doc """
+ Builds a token and its hash to be delivered to the user's email.
+
+ The non-hashed token is sent to the user email while the
+ hashed part is stored in the database. The original token cannot be reconstructed,
+ which means anyone with read-only access to the database cannot directly use
+ the token in the application to gain access. Furthermore, if the user changes
+ their email in the system, the tokens sent to the previous email are no longer
+ valid.
+
+ Users can easily adapt the existing code to provide other types of delivery methods,
+ for example, by phone numbers.
+ """
+ def build_email_token(user, context) do
+ build_hashed_token(user, context, user.email)
+ end
+
+ defp build_hashed_token(user, context, sent_to) do
+ token = :crypto.strong_rand_bytes(@rand_size)
+ hashed_token = :crypto.hash(@hash_algorithm, token)
+
+ {Base.url_encode64(token, padding: false),
+ %Aggiedit.Accounts.UserToken{
+ token: hashed_token,
+ context: context,
+ sent_to: sent_to,
+ user_id: user.id
+ }}
+ end
+
+ @doc """
+ Checks if the token is valid and returns its underlying lookup query.
+
+ The query returns the user found by the token, if any.
+
+ The given token is valid if it matches its hashed counterpart in the
+ database and the user email has not changed. This function also checks
+ if the token is being used within a certain period, depending on the
+ context. The default contexts supported by this function are either
+ "confirm", for account confirmation emails, and "reset_password",
+ for resetting the password. For verifying requests to change the email,
+ see `verify_change_email_token_query/2`.
+ """
+ def verify_email_token_query(token, context) do
+ case Base.url_decode64(token, padding: false) do
+ {:ok, decoded_token} ->
+ hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
+ days = days_for_context(context)
+
+ query =
+ from token in token_and_context_query(hashed_token, context),
+ join: user in assoc(token, :user),
+ where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email,
+ select: user
+
+ {:ok, query}
+
+ :error ->
+ :error
+ end
+ end
+
+ defp days_for_context("confirm"), do: @confirm_validity_in_days
+ defp days_for_context("reset_password"), do: @reset_password_validity_in_days
+
+ @doc """
+ Checks if the token is valid and returns its underlying lookup query.
+
+ The query returns the user found by the token, if any.
+
+ This is used to validate requests to change the user
+ email. It is different from `verify_email_token_query/2` precisely because
+ `verify_email_token_query/2` validates the email has not changed, which is
+ the starting point by this function.
+
+ The given token is valid if it matches its hashed counterpart in the
+ database and if it has not expired (after @change_email_validity_in_days).
+ The context must always start with "change:".
+ """
+ def verify_change_email_token_query(token, "change:" <> _ = context) do
+ case Base.url_decode64(token, padding: false) do
+ {:ok, decoded_token} ->
+ hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
+
+ query =
+ from token in token_and_context_query(hashed_token, context),
+ where: token.inserted_at > ago(@change_email_validity_in_days, "day")
+
+ {:ok, query}
+
+ :error ->
+ :error
+ end
+ end
+
+ @doc """
+ Returns the token struct for the given token value and context.
+ """
+ def token_and_context_query(token, context) do
+ from Aggiedit.Accounts.UserToken, where: [token: ^token, context: ^context]
+ end
+
+ @doc """
+ Gets all tokens for the given user for the given contexts.
+ """
+ def user_and_contexts_query(user, :all) do
+ from t in Aggiedit.Accounts.UserToken, where: t.user_id == ^user.id
+ end
+
+ def user_and_contexts_query(user, [_ | _] = contexts) do
+ from t in Aggiedit.Accounts.UserToken, where: t.user_id == ^user.id and t.context in ^contexts
+ end
+end
diff --git a/lib/aggiedit/application.ex b/lib/aggiedit/application.ex
new file mode 100644
index 0000000..497b9b8
--- /dev/null
+++ b/lib/aggiedit/application.ex
@@ -0,0 +1,36 @@
+defmodule Aggiedit.Application do
+ # See https://hexdocs.pm/elixir/Application.html
+ # for more information on OTP Applications
+ @moduledoc false
+
+ use Application
+
+ @impl true
+ def start(_type, _args) do
+ children = [
+ # Start the Ecto repository
+ Aggiedit.Repo,
+ # Start the Telemetry supervisor
+ AggieditWeb.Telemetry,
+ # Start the PubSub system
+ {Phoenix.PubSub, name: Aggiedit.PubSub},
+ # Start the Endpoint (http/https)
+ AggieditWeb.Endpoint
+ # Start a worker by calling: Aggiedit.Worker.start_link(arg)
+ # {Aggiedit.Worker, arg}
+ ]
+
+ # See https://hexdocs.pm/elixir/Supervisor.html
+ # for other strategies and supported options
+ opts = [strategy: :one_for_one, name: Aggiedit.Supervisor]
+ Supervisor.start_link(children, opts)
+ end
+
+ # Tell Phoenix to update the endpoint configuration
+ # whenever the application is updated.
+ @impl true
+ def config_change(changed, _new, removed) do
+ AggieditWeb.Endpoint.config_change(changed, removed)
+ :ok
+ end
+end
diff --git a/lib/aggiedit/mailer.ex b/lib/aggiedit/mailer.ex
new file mode 100644
index 0000000..35c9937
--- /dev/null
+++ b/lib/aggiedit/mailer.ex
@@ -0,0 +1,3 @@
+defmodule Aggiedit.Mailer do
+ use Swoosh.Mailer, otp_app: :aggiedit
+end
diff --git a/lib/aggiedit/repo.ex b/lib/aggiedit/repo.ex
new file mode 100644
index 0000000..840c2bd
--- /dev/null
+++ b/lib/aggiedit/repo.ex
@@ -0,0 +1,5 @@
+defmodule Aggiedit.Repo do
+ use Ecto.Repo,
+ otp_app: :aggiedit,
+ adapter: Ecto.Adapters.Postgres
+end
diff --git a/lib/aggiedit/rooms.ex b/lib/aggiedit/rooms.ex
new file mode 100644
index 0000000..eac2b65
--- /dev/null
+++ b/lib/aggiedit/rooms.ex
@@ -0,0 +1,111 @@
+defmodule Aggiedit.Rooms do
+ @moduledoc """
+ The Rooms context.
+ """
+
+ import Ecto.Query, warn: false
+ alias Aggiedit.Repo
+
+ alias Aggiedit.Rooms.Room
+
+ @doc """
+ Returns the list of rooms.
+
+ ## Examples
+
+ iex> list_rooms()
+ [%Room{}, ...]
+
+ """
+ def list_rooms do
+ Repo.all(Room)
+ end
+
+ @doc """
+ Gets a single room.
+
+ Raises `Ecto.NoResultsError` if the Room does not exist.
+
+ ## Examples
+
+ iex> get_room!(123)
+ %Room{}
+
+ iex> get_room!(456)
+ ** (Ecto.NoResultsError)
+
+ """
+ def get_room!(id), do: Repo.get!(Room, id)
+
+ @doc """
+ Creates a room.
+
+ ## Examples
+
+ iex> create_room(%{field: value})
+ {:ok, %Room{}}
+
+ iex> create_room(%{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def create_room(attrs \\ %{}) do
+ %Room{}
+ |> Room.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ @doc """
+ Updates a room.
+
+ ## Examples
+
+ iex> update_room(room, %{field: new_value})
+ {:ok, %Room{}}
+
+ iex> update_room(room, %{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def update_room(%Room{} = room, attrs) do
+ room
+ |> Room.changeset(attrs)
+ |> Repo.update()
+ end
+
+ @doc """
+ Deletes a room.
+
+ ## Examples
+
+ iex> delete_room(room)
+ {:ok, %Room{}}
+
+ iex> delete_room(room)
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def delete_room(%Room{} = room) do
+ Repo.delete(room)
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for tracking room changes.
+
+ ## Examples
+
+ iex> change_room(room)
+ %Ecto.Changeset{data: %Room{}}
+
+ """
+ def change_room(%Room{} = room, attrs \\ %{}) do
+ Room.changeset(room, attrs)
+ end
+
+ def create_or_find_room_with_domain(domain) do
+ case Repo.get_by(Room, domain: domain) do
+ room=%Room{} -> {:ok, room}
+ nil -> create_room(%{domain: domain})
+ end
+ end
+end
diff --git a/lib/aggiedit/rooms/room.ex b/lib/aggiedit/rooms/room.ex
new file mode 100644
index 0000000..24f1b8a
--- /dev/null
+++ b/lib/aggiedit/rooms/room.ex
@@ -0,0 +1,22 @@
+defmodule Aggiedit.Rooms.Room do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "rooms" do
+ field :domain, :string
+
+ has_many :users, Aggiedit.Accounts.User
+
+ timestamps()
+ end
+
+ @doc false
+ def changeset(room, attrs) do
+ room
+ |> cast(attrs, [:domain])
+ |> validate_required([:domain])
+ |> validate_length(:domain, max: 160)
+ |> validate_format(:domain, ~r/^[^\s\.]+\.[^\.\s]+$/, message: "Domain cannot be a subdomain, and cannot have spaces")
+ |> unique_constraint(:domain)
+ end
+end
diff --git a/lib/aggiedit/utils.ex b/lib/aggiedit/utils.ex
new file mode 100644
index 0000000..492b36e
--- /dev/null
+++ b/lib/aggiedit/utils.ex
@@ -0,0 +1,14 @@
+defmodule Aggiedit.Utils do
+ def get_email_domain(email) do
+ domain_split = Regex.named_captures(~r/^.*@(?<domain>.*)$/, email)["domain"]
+ |> String.downcase()
+ |> String.split(".")
+ IO.puts(inspect(domain_split))
+
+ if Enum.count(domain_split) >= 2 do
+ Enum.join(Enum.take(domain_split, -2), ".")
+ else
+ nil
+ end
+ end
+end \ No newline at end of file
diff --git a/lib/aggiedit_web.ex b/lib/aggiedit_web.ex
new file mode 100644
index 0000000..0b7717c
--- /dev/null
+++ b/lib/aggiedit_web.ex
@@ -0,0 +1,110 @@
+defmodule AggieditWeb do
+ @moduledoc """
+ The entrypoint for defining your web interface, such
+ as controllers, views, channels and so on.
+
+ This can be used in your application as:
+
+ use AggieditWeb, :controller
+ use AggieditWeb, :view
+
+ The definitions below will be executed for every view,
+ controller, 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.
+ """
+
+ def controller do
+ quote do
+ use Phoenix.Controller, namespace: AggieditWeb
+
+ import Plug.Conn
+ import AggieditWeb.Gettext
+ alias AggieditWeb.Router.Helpers, as: Routes
+ end
+ end
+
+ def view do
+ quote do
+ use Phoenix.View,
+ root: "lib/aggiedit_web/templates",
+ namespace: AggieditWeb
+
+ # 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: {AggieditWeb.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 router do
+ quote do
+ use Phoenix.Router
+
+ import Plug.Conn
+ import Phoenix.Controller
+ import Phoenix.LiveView.Router
+ end
+ end
+
+ def channel do
+ quote do
+ use Phoenix.Channel
+ import AggieditWeb.Gettext
+ end
+ end
+
+ defp view_helpers do
+ quote do
+ # Use all HTML functionality (forms, tags, etc)
+ use Phoenix.HTML
+
+ # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
+ import Phoenix.LiveView.Helpers
+
+ # Import basic rendering functionality (render, render_layout, etc)
+ import Phoenix.View
+
+ import AggieditWeb.ErrorHelpers
+ import AggieditWeb.Gettext
+ alias AggieditWeb.Router.Helpers, as: Routes
+ end
+ end
+
+ @doc """
+ When used, dispatch to the appropriate controller/view/etc.
+ """
+ defmacro __using__(which) when is_atom(which) do
+ apply(__MODULE__, which, [])
+ end
+end
diff --git a/lib/aggiedit_web/controllers/page_controller.ex b/lib/aggiedit_web/controllers/page_controller.ex
new file mode 100644
index 0000000..97e0bf2
--- /dev/null
+++ b/lib/aggiedit_web/controllers/page_controller.ex
@@ -0,0 +1,7 @@
+defmodule AggieditWeb.PageController do
+ use AggieditWeb, :controller
+
+ def index(conn, _params) do
+ render(conn, "index.html")
+ end
+end
diff --git a/lib/aggiedit_web/controllers/user_auth.ex b/lib/aggiedit_web/controllers/user_auth.ex
new file mode 100644
index 0000000..02c2efe
--- /dev/null
+++ b/lib/aggiedit_web/controllers/user_auth.ex
@@ -0,0 +1,170 @@
+defmodule AggieditWeb.UserAuth do
+ import Plug.Conn
+ import Phoenix.Controller
+
+ alias Aggiedit.Accounts
+ alias AggieditWeb.Router.Helpers, as: Routes
+
+ # Make the remember me cookie valid for 60 days.
+ # If you want bump or reduce this value, also change
+ # the token expiry itself in UserToken.
+ @max_age 60 * 60 * 24 * 60
+ @remember_me_cookie "_aggiedit_web_user_remember_me"
+ @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]
+
+ @doc """
+ Logs the user in.
+
+ It renews the session ID and clears the whole session
+ to avoid fixation attacks. See the renew_session
+ function to customize this behaviour.
+
+ It also sets a `:live_socket_id` key in the session,
+ so LiveView sessions are identified and automatically
+ disconnected on log out. The line can be safely removed
+ if you are not using LiveView.
+ """
+ def log_in_user(conn, user, params \\ %{}) do
+ user_return_to = get_session(conn, :user_return_to)
+
+ if user.confirmed_at do
+ token = Accounts.generate_user_session_token(user)
+
+ conn
+ |> renew_session()
+ |> put_session(:user_token, token)
+ |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
+ |> maybe_write_remember_me_cookie(token, params)
+ |> redirect(to: user_return_to || signed_in_path(conn))
+ else
+ conn
+ |> put_flash(:error, "You need to confirm your account first (please check spam).")
+ |> redirect(to: Routes.user_confirmation_path(conn, :new))
+ end
+ end
+
+ defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do
+ put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
+ end
+
+ defp maybe_write_remember_me_cookie(conn, _token, _params) do
+ conn
+ end
+
+ # This function renews the session ID and erases the whole
+ # session to avoid fixation attacks. If there is any data
+ # in the session you may want to preserve after log in/log out,
+ # you must explicitly fetch the session data before clearing
+ # and then immediately set it after clearing, for example:
+ #
+ # defp renew_session(conn) do
+ # preferred_locale = get_session(conn, :preferred_locale)
+ #
+ # conn
+ # |> configure_session(renew: true)
+ # |> clear_session()
+ # |> put_session(:preferred_locale, preferred_locale)
+ # end
+ #
+ defp renew_session(conn) do
+ conn
+ |> configure_session(renew: true)
+ |> clear_session()
+ end
+
+ @doc """
+ Logs the user out.
+
+ It clears all session data for safety. See renew_session.
+ """
+ def log_out_user(conn) do
+ user_token = get_session(conn, :user_token)
+ user_token && Accounts.delete_session_token(user_token)
+
+ if live_socket_id = get_session(conn, :live_socket_id) do
+ AggieditWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
+ end
+
+ conn
+ |> renew_session()
+ |> delete_resp_cookie(@remember_me_cookie)
+ |> redirect(to: "/")
+ end
+
+ @doc """
+ Authenticates the user by looking into the session
+ and remember me token.
+ """
+ def fetch_current_user(conn, _opts) do
+ {user_token, conn} = ensure_user_token(conn)
+ user = user_token && Accounts.get_user_by_session_token(user_token)
+ assign(conn, :current_user, user)
+ end
+
+ defp ensure_user_token(conn) do
+ if user_token = get_session(conn, :user_token) do
+ {user_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)}
+ else
+ {nil, conn}
+ end
+ end
+ end
+
+ @doc """
+ Used for routes that require the user to not be authenticated.
+ """
+ def redirect_if_user_is_authenticated(conn, _opts) do
+ if conn.assigns[:current_user] do
+ conn
+ |> redirect(to: signed_in_path(conn))
+ |> halt()
+ else
+ conn
+ end
+ end
+
+ @doc """
+ Used for routes that require the user to be authenticated.
+
+ If you want to enforce the user email is confirmed before
+ they use the application at all, here would be a good place.
+ """
+ def require_authenticated_user(conn, _opts) do
+ if conn.assigns[:current_user] do
+ conn
+ else
+ conn
+ |> put_flash(:error, "You must log in to access this page.")
+ |> maybe_store_return_to()
+ |> redirect(to: Routes.user_session_path(conn, :new))
+ |> halt()
+ end
+ end
+
+ def require_admin_user(conn, _opts) do
+ user = conn.assigns[:current_user]
+
+ if !!user and user.role == :admin do
+ conn
+ else
+ conn
+ |> put_flash(:error, "You need administrator privileges.")
+ |> maybe_store_return_to()
+ |> redirect(to: Routes.user_session_path(conn, :new))
+ |> halt()
+ end
+ 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: "/"
+end
diff --git a/lib/aggiedit_web/controllers/user_confirmation_controller.ex b/lib/aggiedit_web/controllers/user_confirmation_controller.ex
new file mode 100644
index 0000000..912402a
--- /dev/null
+++ b/lib/aggiedit_web/controllers/user_confirmation_controller.ex
@@ -0,0 +1,56 @@
+defmodule AggieditWeb.UserConfirmationController do
+ use AggieditWeb, :controller
+
+ alias Aggiedit.Accounts
+
+ def new(conn, _params) do
+ render(conn, "new.html")
+ end
+
+ def create(conn, %{"user" => %{"email" => email}}) do
+ if user = Accounts.get_user_by_email(email) do
+ Accounts.deliver_user_confirmation_instructions(
+ user,
+ &Routes.user_confirmation_url(conn, :edit, &1)
+ )
+ end
+
+ conn
+ |> put_flash(
+ :info,
+ "If your email is in our system and it has not been confirmed yet, " <>
+ "you will receive an email with instructions shortly."
+ )
+ |> redirect(to: "/")
+ end
+
+ def edit(conn, %{"token" => token}) do
+ render(conn, "edit.html", token: token)
+ end
+
+ # Do not log in the user after confirmation to avoid a
+ # leaked token giving the user access to the account.
+ def update(conn, %{"token" => token}) do
+ case Accounts.confirm_user(token) do
+ {:ok, _} ->
+ conn
+ |> put_flash(:info, "User confirmed successfully.")
+ |> redirect(to: "/")
+
+ :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 conn.assigns do
+ %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
+ redirect(conn, to: "/")
+
+ %{} ->
+ conn
+ |> put_flash(:error, "User confirmation link is invalid or it has expired.")
+ |> redirect(to: "/")
+ end
+ end
+ end
+end
diff --git a/lib/aggiedit_web/controllers/user_registration_controller.ex b/lib/aggiedit_web/controllers/user_registration_controller.ex
new file mode 100644
index 0000000..c8a4d4a
--- /dev/null
+++ b/lib/aggiedit_web/controllers/user_registration_controller.ex
@@ -0,0 +1,30 @@
+defmodule AggieditWeb.UserRegistrationController do
+ use AggieditWeb, :controller
+
+ alias Aggiedit.Accounts
+ alias Aggiedit.Accounts.User
+ alias AggieditWeb.UserAuth
+
+ def new(conn, _params) do
+ changeset = Accounts.change_user_registration(%User{})
+ render(conn, "new.html", changeset: changeset)
+ end
+
+ def create(conn, %{"user" => user_params}) do
+ case Accounts.register_user(user_params) do
+ {:ok, user} ->
+ {:ok, _} =
+ Accounts.deliver_user_confirmation_instructions(
+ user,
+ &Routes.user_confirmation_url(conn, :edit, &1)
+ )
+
+ conn
+ |> put_flash(:info, "User created successfully.")
+ |> UserAuth.log_in_user(user)
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ render(conn, "new.html", changeset: changeset)
+ end
+ end
+end
diff --git a/lib/aggiedit_web/controllers/user_reset_password_controller.ex b/lib/aggiedit_web/controllers/user_reset_password_controller.ex
new file mode 100644
index 0000000..ff2a9f6
--- /dev/null
+++ b/lib/aggiedit_web/controllers/user_reset_password_controller.ex
@@ -0,0 +1,58 @@
+defmodule AggieditWeb.UserResetPasswordController do
+ use AggieditWeb, :controller
+
+ alias Aggiedit.Accounts
+
+ plug :get_user_by_reset_password_token when action in [:edit, :update]
+
+ def new(conn, _params) do
+ render(conn, "new.html")
+ end
+
+ def create(conn, %{"user" => %{"email" => email}}) do
+ if user = Accounts.get_user_by_email(email) do
+ Accounts.deliver_user_reset_password_instructions(
+ user,
+ &Routes.user_reset_password_url(conn, :edit, &1)
+ )
+ end
+
+ conn
+ |> put_flash(
+ :info,
+ "If your email is in our system, you will receive instructions to reset your password shortly."
+ )
+ |> redirect(to: "/")
+ end
+
+ def edit(conn, _params) do
+ render(conn, "edit.html", changeset: Accounts.change_user_password(conn.assigns.user))
+ end
+
+ # Do not log in the user after reset password to avoid a
+ # leaked token giving the user access to the account.
+ def update(conn, %{"user" => user_params}) do
+ case Accounts.reset_user_password(conn.assigns.user, user_params) do
+ {:ok, _} ->
+ conn
+ |> put_flash(:info, "Password reset successfully.")
+ |> redirect(to: Routes.user_session_path(conn, :new))
+
+ {:error, changeset} ->
+ render(conn, "edit.html", changeset: changeset)
+ end
+ end
+
+ defp get_user_by_reset_password_token(conn, _opts) do
+ %{"token" => token} = conn.params
+
+ if user = Accounts.get_user_by_reset_password_token(token) do
+ conn |> assign(:user, user) |> assign(:token, token)
+ else
+ conn
+ |> put_flash(:error, "Reset password link is invalid or it has expired.")
+ |> redirect(to: "/")
+ |> halt()
+ end
+ end
+end
diff --git a/lib/aggiedit_web/controllers/user_session_controller.ex b/lib/aggiedit_web/controllers/user_session_controller.ex
new file mode 100644
index 0000000..fc20cc1
--- /dev/null
+++ b/lib/aggiedit_web/controllers/user_session_controller.ex
@@ -0,0 +1,27 @@
+defmodule AggieditWeb.UserSessionController do
+ use AggieditWeb, :controller
+
+ alias Aggiedit.Accounts
+ alias AggieditWeb.UserAuth
+
+ def new(conn, _params) do
+ render(conn, "new.html", error_message: nil)
+ end
+
+ def create(conn, %{"user" => user_params}) do
+ %{"email" => email, "password" => password} = user_params
+
+ if user = Accounts.get_user_by_email_and_password(email, password) do
+ UserAuth.log_in_user(conn, user, user_params)
+ else
+ # In order to prevent user enumeration attacks, don't disclose whether the email is registered.
+ render(conn, "new.html", error_message: "Invalid email or password")
+ end
+ end
+
+ def delete(conn, _params) do
+ conn
+ |> put_flash(:info, "Logged out successfully.")
+ |> UserAuth.log_out_user()
+ end
+end
diff --git a/lib/aggiedit_web/controllers/user_settings_controller.ex b/lib/aggiedit_web/controllers/user_settings_controller.ex
new file mode 100644
index 0000000..0f83a96
--- /dev/null
+++ b/lib/aggiedit_web/controllers/user_settings_controller.ex
@@ -0,0 +1,74 @@
+defmodule AggieditWeb.UserSettingsController do
+ use AggieditWeb, :controller
+
+ alias Aggiedit.Accounts
+ alias AggieditWeb.UserAuth
+
+ plug :assign_email_and_password_changesets
+
+ def edit(conn, _params) do
+ render(conn, "edit.html")
+ end
+
+ def update(conn, %{"action" => "update_email"} = params) do
+ %{"current_password" => password, "user" => user_params} = params
+ user = conn.assigns.current_user
+
+ case Accounts.apply_user_email(user, password, user_params) do
+ {:ok, applied_user} ->
+ Accounts.deliver_update_email_instructions(
+ applied_user,
+ user.email,
+ &Routes.user_settings_url(conn, :confirm_email, &1)
+ )
+
+ conn
+ |> put_flash(
+ :info,
+ "A link to confirm your email change has been sent to the new address."
+ )
+ |> redirect(to: Routes.user_settings_path(conn, :edit))
+
+ {:error, changeset} ->
+ render(conn, "edit.html", email_changeset: changeset)
+ end
+ end
+
+ def update(conn, %{"action" => "update_password"} = params) do
+ %{"current_password" => password, "user" => user_params} = params
+ user = conn.assigns.current_user
+
+ case Accounts.update_user_password(user, password, user_params) do
+ {:ok, user} ->
+ conn
+ |> put_flash(:info, "Password updated successfully.")
+ |> put_session(:user_return_to, Routes.user_settings_path(conn, :edit))
+ |> UserAuth.log_in_user(user)
+
+ {:error, changeset} ->
+ render(conn, "edit.html", password_changeset: changeset)
+ end
+ end
+
+ def confirm_email(conn, %{"token" => token}) do
+ case Accounts.update_user_email(conn.assigns.current_user, token) do
+ :ok ->
+ conn
+ |> put_flash(:info, "Email changed successfully.")
+ |> redirect(to: Routes.user_settings_path(conn, :edit))
+
+ :error ->
+ conn
+ |> put_flash(:error, "Email change link is invalid or it has expired.")
+ |> redirect(to: Routes.user_settings_path(conn, :edit))
+ end
+ end
+
+ defp assign_email_and_password_changesets(conn, _opts) do
+ user = conn.assigns.current_user
+
+ conn
+ |> assign(:email_changeset, Accounts.change_user_email(user))
+ |> assign(:password_changeset, Accounts.change_user_password(user))
+ end
+end
diff --git a/lib/aggiedit_web/endpoint.ex b/lib/aggiedit_web/endpoint.ex
new file mode 100644
index 0000000..47209ad
--- /dev/null
+++ b/lib/aggiedit_web/endpoint.ex
@@ -0,0 +1,50 @@
+defmodule AggieditWeb.Endpoint do
+ use Phoenix.Endpoint, otp_app: :aggiedit
+
+ # The session will be stored in the cookie and signed,
+ # this means its contents can be read but not tampered with.
+ # Set :encryption_salt if you would also like to encrypt it.
+ @session_options [
+ store: :cookie,
+ key: "_aggiedit_key",
+ signing_salt: "yXlQsIK6"
+ ]
+
+ socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
+
+ # Serve at "/" the static files from "priv/static" directory.
+ #
+ # You should set gzip to true if you are running phx.digest
+ # when deploying your static files in production.
+ plug Plug.Static,
+ at: "/",
+ from: :aggiedit,
+ gzip: false,
+ only: ~w(assets fonts images favicon.ico robots.txt)
+
+ # Code reloading can be explicitly enabled under the
+ # :code_reloader configuration of your endpoint.
+ if code_reloading? do
+ socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
+ plug Phoenix.LiveReloader
+ plug Phoenix.CodeReloader
+ plug Phoenix.Ecto.CheckRepoStatus, otp_app: :aggiedit
+ end
+
+ plug Phoenix.LiveDashboard.RequestLogger,
+ param_key: "request_logger",
+ cookie_key: "request_logger"
+
+ plug Plug.RequestId
+ plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
+
+ plug Plug.Parsers,
+ parsers: [:urlencoded, :multipart, :json],
+ pass: ["*/*"],
+ json_decoder: Phoenix.json_library()
+
+ plug Plug.MethodOverride
+ plug Plug.Head
+ plug Plug.Session, @session_options
+ plug AggieditWeb.Router
+end
diff --git a/lib/aggiedit_web/gettext.ex b/lib/aggiedit_web/gettext.ex
new file mode 100644
index 0000000..7cb0bef
--- /dev/null
+++ b/lib/aggiedit_web/gettext.ex
@@ -0,0 +1,24 @@
+defmodule AggieditWeb.Gettext do
+ @moduledoc """
+ A module providing Internationalization with a gettext-based API.
+
+ By using [Gettext](https://hexdocs.pm/gettext),
+ your module gains a set of macros for translations, for example:
+
+ import AggieditWeb.Gettext
+
+ # Simple translation
+ gettext("Here is the string to translate")
+
+ # Plural translation
+ ngettext("Here is the string to translate",
+ "Here are the strings to translate",
+ 3)
+
+ # Domain-based translation
+ dgettext("errors", "Here is the error message to translate")
+
+ See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
+ """
+ use Gettext, otp_app: :aggiedit
+end
diff --git a/lib/aggiedit_web/router.ex b/lib/aggiedit_web/router.ex
new file mode 100644
index 0000000..e400391
--- /dev/null
+++ b/lib/aggiedit_web/router.ex
@@ -0,0 +1,92 @@
+defmodule AggieditWeb.Router do
+ use AggieditWeb, :router
+
+ import AggieditWeb.UserAuth
+
+ pipeline :browser do
+ plug :accepts, ["html"]
+ plug :fetch_session
+ plug :fetch_live_flash
+ plug :put_root_layout, {AggieditWeb.LayoutView, :root}
+ plug :protect_from_forgery
+ plug :put_secure_browser_headers
+ plug :fetch_current_user
+ end
+
+ pipeline :api do
+ plug :accepts, ["json"]
+ end
+
+ scope "/", AggieditWeb do
+ pipe_through :browser
+
+ get "/", PageController, :index
+ end
+
+ # Other scopes may use custom stacks.
+ # scope "/api", AggieditWeb 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
+ import Phoenix.LiveDashboard.Router
+
+ scope "/" do
+ pipe_through :browser
+
+ live_dashboard "/dashboard", metrics: AggieditWeb.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
+
+ forward "/mailbox", Plug.Swoosh.MailboxPreview
+ end
+ end
+
+ ## Authentication routes
+
+ scope "/", AggieditWeb 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
+ 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 "/", AggieditWeb 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
+ end
+
+ scope "/", AggieditWeb 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
+ end
+end
diff --git a/lib/aggiedit_web/telemetry.ex b/lib/aggiedit_web/telemetry.ex
new file mode 100644
index 0000000..358bf80
--- /dev/null
+++ b/lib/aggiedit_web/telemetry.ex
@@ -0,0 +1,71 @@
+defmodule AggieditWeb.Telemetry do
+ use Supervisor
+ import Telemetry.Metrics
+
+ def start_link(arg) do
+ Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
+ end
+
+ @impl true
+ def init(_arg) do
+ children = [
+ # Telemetry poller will execute the given period measurements
+ # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
+ {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
+ # Add reporters as children of your supervision tree.
+ # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
+ ]
+
+ Supervisor.init(children, strategy: :one_for_one)
+ end
+
+ def metrics do
+ [
+ # Phoenix Metrics
+ summary("phoenix.endpoint.stop.duration",
+ unit: {:native, :millisecond}
+ ),
+ summary("phoenix.router_dispatch.stop.duration",
+ tags: [:route],
+ unit: {:native, :millisecond}
+ ),
+
+ # Database Metrics
+ summary("aggiedit.repo.query.total_time",
+ unit: {:native, :millisecond},
+ description: "The sum of the other measurements"
+ ),
+ summary("aggiedit.repo.query.decode_time",
+ unit: {:native, :millisecond},
+ description: "The time spent decoding the data received from the database"
+ ),
+ summary("aggiedit.repo.query.query_time",
+ unit: {:native, :millisecond},
+ description: "The time spent executing the query"
+ ),
+ summary("aggiedit.repo.query.queue_time",
+ unit: {:native, :millisecond},
+ description: "The time spent waiting for a database connection"
+ ),
+ summary("aggiedit.repo.query.idle_time",
+ unit: {:native, :millisecond},
+ description:
+ "The time the connection spent waiting before being checked out for the query"
+ ),
+
+ # VM Metrics
+ summary("vm.memory.total", unit: {:byte, :kilobyte}),
+ summary("vm.total_run_queue_lengths.total"),
+ summary("vm.total_run_queue_lengths.cpu"),
+ summary("vm.total_run_queue_lengths.io")
+ ]
+ end
+
+ defp periodic_measurements do
+ [
+ # A module, function and arguments to be invoked periodically.
+ # This function must call :telemetry.execute/3 and a metric must be added above.
+ # {AggieditWeb, :count_users, []}
+ ]
+ end
+end
diff --git a/lib/aggiedit_web/templates/layout/_user_menu.html.heex b/lib/aggiedit_web/templates/layout/_user_menu.html.heex
new file mode 100644
index 0000000..f281cfc
--- /dev/null
+++ b/lib/aggiedit_web/templates/layout/_user_menu.html.heex
@@ -0,0 +1,10 @@
+<ul>
+<%= if @current_user do %>
+ <li><%= @current_user.email %></li>
+ <li><%= link "Settings", to: Routes.user_settings_path(@conn, :edit) %></li>
+ <li><%= link "Log out", to: Routes.user_session_path(@conn, :delete), method: :delete %></li>
+<% else %>
+ <li><%= link "Register", to: Routes.user_registration_path(@conn, :new) %></li>
+ <li><%= link "Log in", to: Routes.user_session_path(@conn, :new) %></li>
+<% end %>
+</ul>
diff --git a/lib/aggiedit_web/templates/layout/app.html.heex b/lib/aggiedit_web/templates/layout/app.html.heex
new file mode 100644
index 0000000..169aed9
--- /dev/null
+++ b/lib/aggiedit_web/templates/layout/app.html.heex
@@ -0,0 +1,5 @@
+<main class="container">
+ <p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
+ <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
+ <%= @inner_content %>
+</main>
diff --git a/lib/aggiedit_web/templates/layout/live.html.heex b/lib/aggiedit_web/templates/layout/live.html.heex
new file mode 100644
index 0000000..a29d604
--- /dev/null
+++ b/lib/aggiedit_web/templates/layout/live.html.heex
@@ -0,0 +1,11 @@
+<main class="container">
+ <p class="alert alert-info" role="alert"
+ phx-click="lv:clear-flash"
+ phx-value-key="info"><%= live_flash(@flash, :info) %></p>
+
+ <p class="alert alert-danger" role="alert"
+ phx-click="lv:clear-flash"
+ phx-value-key="error"><%= live_flash(@flash, :error) %></p>
+
+ <%= @inner_content %>
+</main>
diff --git a/lib/aggiedit_web/templates/layout/root.html.heex b/lib/aggiedit_web/templates/layout/root.html.heex
new file mode 100644
index 0000000..f653fd8
--- /dev/null
+++ b/lib/aggiedit_web/templates/layout/root.html.heex
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8"/>
+ <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+ <%= csrf_meta_tag() %>
+ <%= live_title_tag assigns[:page_title] || "Aggiedit", suffix: " · Phoenix Framework" %>
+ <link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/assets/app.css")}/>
+ <script defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/assets/app.js")}></script>
+ </head>
+ <body>
+ <header>
+ <section class="container">
+ <nav>
+ <ul>
+ <li><a href="https://hexdocs.pm/phoenix/overview.html">Get Started</a></li>
+ <%= if function_exported?(Routes, :live_dashboard_path, 2) do %>
+ <li><%= link "LiveDashboard", to: Routes.live_dashboard_path(@conn, :home) %></li>
+ <% end %>
+ </ul>
+ <%= render "_user_menu.html", assigns %>
+ </nav>
+ <a href="https://phoenixframework.org/" class="phx-logo">
+ <img src={Routes.static_path(@conn, "/images/phoenix.png")} alt="Phoenix Framework Logo"/>
+ </a>
+ </section>
+ </header>
+ <%= @inner_content %>
+ </body>
+</html>
diff --git a/lib/aggiedit_web/templates/page/index.html.heex b/lib/aggiedit_web/templates/page/index.html.heex
new file mode 100644
index 0000000..f844bd8
--- /dev/null
+++ b/lib/aggiedit_web/templates/page/index.html.heex
@@ -0,0 +1,41 @@
+<section class="phx-hero">
+ <h1><%= gettext "Welcome to %{name}!", name: "Phoenix" %></h1>
+ <p>Peace of mind from prototype to production</p>
+</section>
+
+<section class="row">
+ <article class="column">
+ <h2>Resources</h2>
+ <ul>
+ <li>
+ <a href="https://hexdocs.pm/phoenix/overview.html">Guides &amp; Docs</a>
+ </li>
+ <li>
+ <a href="https://github.com/phoenixframework/phoenix">Source</a>
+ </li>
+ <li>
+ <a href="https://github.com/phoenixframework/phoenix/blob/v1.6/CHANGELOG.md">v1.6 Changelog</a>
+ </li>
+ </ul>
+ </article>
+ <article class="column">
+ <h2>Help</h2>
+ <ul>
+ <li>
+ <a href="https://elixirforum.com/c/phoenix-forum">Forum</a>
+ </li>
+ <li>
+ <a href="https://web.libera.chat/#elixir">#elixir on Libera Chat (IRC)</a>
+ </li>
+ <li>
+ <a href="https://twitter.com/elixirphoenix">Twitter @elixirphoenix</a>
+ </li>
+ <li>
+ <a href="https://elixir-slackin.herokuapp.com/">Elixir on Slack</a>
+ </li>
+ <li>
+ <a href="https://discord.gg/elixir">Elixir on Discord</a>
+ </li>
+ </ul>
+ </article>
+</section>
diff --git a/lib/aggiedit_web/templates/user_confirmation/edit.html.heex b/lib/aggiedit_web/templates/user_confirmation/edit.html.heex
new file mode 100644
index 0000000..e9bf443
--- /dev/null
+++ b/lib/aggiedit_web/templates/user_confirmation/edit.html.heex
@@ -0,0 +1,12 @@
+<h1>Confirm account</h1>
+
+<.form let={_f} for={:user} action={Routes.user_confirmation_path(@conn, :update, @token)}>
+ <div>
+ <%= submit "Confirm my account" %>
+ </div>
+</.form>
+
+<p>
+ <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
+ <%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
+</p>
diff --git a/lib/aggiedit_web/templates/user_confirmation/new.html.heex b/lib/aggiedit_web/templates/user_confirmation/new.html.heex
new file mode 100644
index 0000000..4d9bee3
--- /dev/null
+++ b/lib/aggiedit_web/templates/user_confirmation/new.html.heex
@@ -0,0 +1,15 @@
+<h1>Resend confirmation instructions</h1>
+
+<.form let={f} for={:user} action={Routes.user_confirmation_path(@conn, :create)}>
+ <%= label f, :email %>
+ <%= email_input f, :email, required: true %>
+
+ <div>
+ <%= submit "Resend confirmation instructions" %>
+ </div>
+</.form>
+
+<p>
+ <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
+ <%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
+</p>
diff --git a/lib/aggiedit_web/templates/user_registration/new.html.heex b/lib/aggiedit_web/templates/user_registration/new.html.heex
new file mode 100644
index 0000000..6ff00fc
--- /dev/null
+++ b/lib/aggiedit_web/templates/user_registration/new.html.heex
@@ -0,0 +1,30 @@
+<h1>Register</h1>
+
+<.form let={f} for={@changeset} action={Routes.user_registration_path(@conn, :create)}>
+ <%= if @changeset.action do %>
+ <div class="alert alert-danger">
+ <p>Oops, something went wrong! Please check the errors below.</p>
+ </div>
+ <% end %>
+
+ <%= label f, :email %>
+ <%= email_input f, :email, required: true %>
+ <%= error_tag f, :email %>
+
+ <%= label f, :username %>
+ <%= text_input f, :username, required: true %>
+ <%= error_tag f, :username %>
+
+ <%= label f, :password %>
+ <%= password_input f, :password, required: true %>
+ <%= error_tag f, :password %>
+
+ <div>
+ <%= submit "Register" %>
+ </div>
+</.form>
+
+<p>
+ <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> |
+ <%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %>
+</p>
diff --git a/lib/aggiedit_web/templates/user_reset_password/edit.html.heex b/lib/aggiedit_web/templates/user_reset_password/edit.html.heex
new file mode 100644
index 0000000..d8efb4b
--- /dev/null
+++ b/lib/aggiedit_web/templates/user_reset_password/edit.html.heex
@@ -0,0 +1,26 @@
+<h1>Reset password</h1>
+
+<.form let={f} for={@changeset} action={Routes.user_reset_password_path(@conn, :update, @token)}>
+ <%= if @changeset.action do %>
+ <div class="alert alert-danger">
+ <p>Oops, something went wrong! Please check the errors below.</p>
+ </div>
+ <% 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 %>
+
+ <div>
+ <%= submit "Reset password" %>
+ </div>
+</.form>
+
+<p>
+ <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
+ <%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
+</p>
diff --git a/lib/aggiedit_web/templates/user_reset_password/new.html.heex b/lib/aggiedit_web/templates/user_reset_password/new.html.heex
new file mode 100644
index 0000000..126cdba
--- /dev/null
+++ b/lib/aggiedit_web/templates/user_reset_password/new.html.heex
@@ -0,0 +1,15 @@
+<h1>Forgot your password?</h1>
+
+<.form let={f} for={:user} action={Routes.user_reset_password_path(@conn, :create)}>
+ <%= label f, :email %>
+ <%= email_input f, :email, required: true %>
+
+ <div>
+ <%= submit "Send instructions to reset password" %>
+ </div>
+</.form>
+
+<p>
+ <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
+ <%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
+</p>
diff --git a/lib/aggiedit_web/templates/user_session/new.html.heex b/lib/aggiedit_web/templates/user_session/new.html.heex
new file mode 100644
index 0000000..49a7d79
--- /dev/null
+++ b/lib/aggiedit_web/templates/user_session/new.html.heex
@@ -0,0 +1,27 @@
+<h1>Log in</h1>
+
+<.form let={f} for={@conn} action={Routes.user_session_path(@conn, :create)} as={:user}>
+ <%= if @error_message do %>
+ <div class="alert alert-danger">
+ <p><%= @error_message %></p>
+ </div>
+ <% 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 %>
+
+ <div>
+ <%= submit "Log in" %>
+ </div>
+</.form>
+
+<p>
+ <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
+ <%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %>
+</p>
diff --git a/lib/aggiedit_web/templates/user_settings/edit.html.heex b/lib/aggiedit_web/templates/user_settings/edit.html.heex
new file mode 100644
index 0000000..9863bc5
--- /dev/null
+++ b/lib/aggiedit_web/templates/user_settings/edit.html.heex
@@ -0,0 +1,53 @@
+<h1>Settings</h1>
+
+<h3>Change email</h3>
+
+<.form let={f} for={@email_changeset} action={Routes.user_settings_path(@conn, :update)} id="update_email">
+ <%= if @email_changeset.action do %>
+ <div class="alert alert-danger">
+ <p>Oops, something went wrong! Please check the errors below.</p>
+ </div>
+ <% 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 %>
+
+ <div>
+ <%= submit "Change email" %>
+ </div>
+</.form>
+
+<h3>Change password</h3>
+
+<.form let={f} for={@password_changeset} action={Routes.user_settings_path(@conn, :update)} id="update_password">
+ <%= if @password_changeset.action do %>
+ <div class="alert alert-danger">
+ <p>Oops, something went wrong! Please check the errors below.</p>
+ </div>
+ <% 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 %>
+
+ <div>
+ <%= submit "Change password" %>
+ </div>
+</.form>
diff --git a/lib/aggiedit_web/views/error_helpers.ex b/lib/aggiedit_web/views/error_helpers.ex
new file mode 100644
index 0000000..651e48f
--- /dev/null
+++ b/lib/aggiedit_web/views/error_helpers.ex
@@ -0,0 +1,47 @@
+defmodule AggieditWeb.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(AggieditWeb.Gettext, "errors", msg, msg, count, opts)
+ else
+ Gettext.dgettext(AggieditWeb.Gettext, "errors", msg, opts)
+ end
+ end
+end
diff --git a/lib/aggiedit_web/views/error_view.ex b/lib/aggiedit_web/views/error_view.ex
new file mode 100644
index 0000000..3615b3b
--- /dev/null
+++ b/lib/aggiedit_web/views/error_view.ex
@@ -0,0 +1,16 @@
+defmodule AggieditWeb.ErrorView do
+ use AggieditWeb, :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/aggiedit_web/views/layout_view.ex b/lib/aggiedit_web/views/layout_view.ex
new file mode 100644
index 0000000..8f4b14f
--- /dev/null
+++ b/lib/aggiedit_web/views/layout_view.ex
@@ -0,0 +1,7 @@
+defmodule AggieditWeb.LayoutView do
+ use AggieditWeb, :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/aggiedit_web/views/page_view.ex b/lib/aggiedit_web/views/page_view.ex
new file mode 100644
index 0000000..f7e6157
--- /dev/null
+++ b/lib/aggiedit_web/views/page_view.ex
@@ -0,0 +1,3 @@
+defmodule AggieditWeb.PageView do
+ use AggieditWeb, :view
+end
diff --git a/lib/aggiedit_web/views/user_confirmation_view.ex b/lib/aggiedit_web/views/user_confirmation_view.ex
new file mode 100644
index 0000000..aa856e4
--- /dev/null
+++ b/lib/aggiedit_web/views/user_confirmation_view.ex
@@ -0,0 +1,3 @@
+defmodule AggieditWeb.UserConfirmationView do
+ use AggieditWeb, :view
+end
diff --git a/lib/aggiedit_web/views/user_registration_view.ex b/lib/aggiedit_web/views/user_registration_view.ex
new file mode 100644
index 0000000..cf3d53d
--- /dev/null
+++ b/lib/aggiedit_web/views/user_registration_view.ex
@@ -0,0 +1,3 @@
+defmodule AggieditWeb.UserRegistrationView do
+ use AggieditWeb, :view
+end
diff --git a/lib/aggiedit_web/views/user_reset_password_view.ex b/lib/aggiedit_web/views/user_reset_password_view.ex
new file mode 100644
index 0000000..40d6f4c
--- /dev/null
+++ b/lib/aggiedit_web/views/user_reset_password_view.ex
@@ -0,0 +1,3 @@
+defmodule AggieditWeb.UserResetPasswordView do
+ use AggieditWeb, :view
+end
diff --git a/lib/aggiedit_web/views/user_session_view.ex b/lib/aggiedit_web/views/user_session_view.ex
new file mode 100644
index 0000000..b87ce6b
--- /dev/null
+++ b/lib/aggiedit_web/views/user_session_view.ex
@@ -0,0 +1,3 @@
+defmodule AggieditWeb.UserSessionView do
+ use AggieditWeb, :view
+end
diff --git a/lib/aggiedit_web/views/user_settings_view.ex b/lib/aggiedit_web/views/user_settings_view.ex
new file mode 100644
index 0000000..8257d7e
--- /dev/null
+++ b/lib/aggiedit_web/views/user_settings_view.ex
@@ -0,0 +1,3 @@
+defmodule AggieditWeb.UserSettingsView do
+ use AggieditWeb, :view
+end
diff --git a/mix.exs b/mix.exs
new file mode 100644
index 0000000..7e9aa29
--- /dev/null
+++ b/mix.exs
@@ -0,0 +1,72 @@
+defmodule Aggiedit.MixProject do
+ use Mix.Project
+
+ def project do
+ [
+ app: :aggiedit,
+ version: "0.1.0",
+ elixir: "~> 1.12",
+ elixirc_paths: elixirc_paths(Mix.env()),
+ compilers: [:gettext] ++ Mix.compilers(),
+ start_permanent: Mix.env() == :prod,
+ aliases: aliases(),
+ deps: deps()
+ ]
+ end
+
+ # Configuration for the OTP application.
+ #
+ # Type `mix help compile.app` for more information.
+ def application do
+ [
+ mod: {Aggiedit.Application, []},
+ extra_applications: [:logger, :runtime_tools]
+ ]
+ end
+
+ # Specifies which paths to compile per environment.
+ defp elixirc_paths(:test), do: ["lib", "test/support"]
+ defp elixirc_paths(_), do: ["lib"]
+
+ # Specifies your project dependencies.
+ #
+ # Type `mix help deps` for examples and options.
+ defp deps do
+ [
+ {:bcrypt_elixir, "~> 2.0"},
+ {:phoenix, "~> 1.6.6"},
+ {: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"},
+ {:floki, ">= 0.30.0", only: :test},
+ {:phoenix_live_dashboard, "~> 0.6"},
+ {:esbuild, "~> 0.3", runtime: Mix.env() == :dev},
+ {:swoosh, "~> 1.3"},
+ {:telemetry_metrics, "~> 0.6"},
+ {:telemetry_poller, "~> 1.0"},
+ {:gettext, "~> 0.18"},
+ {:jason, "~> 1.2"},
+ {:plug_cowboy, "~> 2.5"},
+ {:ecto_enum, "~> 1.4"},
+ ]
+ end
+
+ # Aliases are shortcuts or tasks specific to the current project.
+ # For example, to install project dependencies and perform other setup tasks, run:
+ #
+ # $ mix setup
+ #
+ # See the documentation for `Mix` for more info on aliases.
+ defp aliases do
+ [
+ setup: ["deps.get", "ecto.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": ["esbuild default --minify", "phx.digest"]
+ ]
+ end
+end
diff --git a/mix.lock b/mix.lock
new file mode 100644
index 0000000..edf374a
--- /dev/null
+++ b/mix.lock
@@ -0,0 +1,39 @@
+%{
+ "bcrypt_elixir": {:hex, :bcrypt_elixir, "2.3.1", "5114d780459a04f2b4aeef52307de23de961b69e13a5cd98a911e39fda13f420", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "42182d5f46764def15bf9af83739e3bf4ad22661b1c34fc3e88558efced07279"},
+ "castore": {:hex, :castore, "0.1.16", "2675f717adc700475345c5512c381ef9273eb5df26bdd3f8c13e2636cf4cc175", [:mix], [], "hexpm", "28ed2c43d83b5c25d35c51bc0abf229ac51359c170cba76171a462ced2e4b651"},
+ "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"},
+ "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"},
+ "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
+ "ecto": {:hex, :ecto, "3.7.2", "44c034f88e1980754983cc4400585970b4206841f6f3780967a65a9150ef09a8", [: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", "a600da5772d1c31abbf06f3e4a1ffb150e74ed3e2aa92ff3cee95901657a874e"},
+ "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"},
+ "ecto_sql": {:hex, :ecto_sql, "3.7.2", "55c60aa3a06168912abf145c6df38b0295c34118c3624cf7a6977cd6ce043081", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0 or ~> 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", "3c218ea62f305dcaef0b915fb56583195e7b91c91dcfb006ba1f669bfacbff2a"},
+ "elixir_make": {:hex, :elixir_make, "0.6.3", "bc07d53221216838d79e03a8019d0839786703129599e9619f4ab74c8c096eac", [:mix], [], "hexpm", "f5cbd651c5678bcaabdbb7857658ee106b12509cd976c2c2fca99688e1daf716"},
+ "esbuild": {:hex, :esbuild, "0.4.0", "9f17db148aead4cf1e6e6a584214357287a93407b5fb51a031f122b61385d4c2", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "b61e4e6b92ffe45e4ee4755a22de6211a67c67987dc02afb35a425a0add1d447"},
+ "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
+ "floki": {:hex, :floki, "0.32.1", "dfe3b8db3b793939c264e6f785bca01753d17318d144bd44b407fb3493acaa87", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "d4b91c713e4a784a3f7b1e3cc016eefc619f6b1c3898464222867cafd3c681a3"},
+ "gettext": {:hex, :gettext, "0.19.1", "564953fd21f29358e68b91634799d9d26989f8d039d7512622efb3c3b1c97892", [:mix], [], "hexpm", "10c656c0912b8299adba9b061c06947511e3f109ab0d18b44a866a4498e77222"},
+ "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
+ "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"},
+ "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"},
+ "phoenix": {:hex, :phoenix, "1.6.6", "281c8ce8dccc9f60607346b72cdfc597c3dde134dd9df28dff08282f0b751754", [: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", "807bd646e64cd9dc83db016199715faba72758e6db1de0707eef0a2da4924364"},
+ "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.7", "05a42377075868a678d446361effba80cefef19ab98941c01a7a4c7560b29121", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.9 or ~> 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", "25eaf41028eb351b90d4f69671874643a09944098fefd0d01d442f40a6091b6f"},
+ "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.0", "5ea4036a3c8f372e6fbf928c822b16028bcaaf2b26ea83d5775670498af7bd92", [:mix], [], "hexpm", "fe61113eff12693a758080ac595dc86bfe3744d4734520a96f6c1a0d7f13c126"},
+ "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.4", "addb6e125347226e3b11489e23d22a60f7ab74786befb86c14f94fb5f23ca9a4", [: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", "06114c1f2a334212fe3ae567dbb3b1d29fd492c1a09783d52f3d489c1a6f4cf2"},
+ "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.2", "0f83198d0e73a36e8d716b90f45f3bde75b5eebf4ade4f43fa1f88c90a812f74", [: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]}], "hexpm", "a9ea589754d9d4d076121090662b7afe155b374897a6550eb288f11d755acfa0"},
+ "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
+ "swoosh": {:hex, :swoosh, "1.6.3", "598d3f07641004bedb3eede40057760ae18be1073cff72f079ca1e1fc9cd97b9", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, 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", "81ff9d7c7c4005a57465a7eb712edd71db51829aef94c8a34c30c5b9e9964adf"},
+ "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"},
+ "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"},
+}
diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po
new file mode 100644
index 0000000..844c4f5
--- /dev/null
+++ b/priv/gettext/en/LC_MESSAGES/errors.po
@@ -0,0 +1,112 @@
+## `msgid`s in this file come from POT (.pot) files.
+##
+## Do not add, change, or remove `msgid`s manually here as
+## they're tied to the ones in the corresponding POT file
+## (with the same domain).
+##
+## Use `mix gettext.extract --merge` or `mix gettext.merge`
+## to merge POT files into PO files.
+msgid ""
+msgstr ""
+"Language: en\n"
+
+## From Ecto.Changeset.cast/4
+msgid "can't be blank"
+msgstr ""
+
+## From Ecto.Changeset.unique_constraint/3
+msgid "has already been taken"
+msgstr ""
+
+## From Ecto.Changeset.put_change/3
+msgid "is invalid"
+msgstr ""
+
+## From Ecto.Changeset.validate_acceptance/3
+msgid "must be accepted"
+msgstr ""
+
+## From Ecto.Changeset.validate_format/3
+msgid "has invalid format"
+msgstr ""
+
+## From Ecto.Changeset.validate_subset/3
+msgid "has an invalid entry"
+msgstr ""
+
+## From Ecto.Changeset.validate_exclusion/3
+msgid "is reserved"
+msgstr ""
+
+## From Ecto.Changeset.validate_confirmation/3
+msgid "does not match confirmation"
+msgstr ""
+
+## From Ecto.Changeset.no_assoc_constraint/3
+msgid "is still associated with this entry"
+msgstr ""
+
+msgid "are still associated with this entry"
+msgstr ""
+
+## From Ecto.Changeset.validate_length/3
+msgid "should have %{count} item(s)"
+msgid_plural "should have %{count} item(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+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] ""
+
+msgid "should have at least %{count} item(s)"
+msgid_plural "should have at least %{count} item(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+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] ""
+
+msgid "should have at most %{count} item(s)"
+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 ""
+
+msgid "must be greater than %{number}"
+msgstr ""
+
+msgid "must be less than or equal to %{number}"
+msgstr ""
+
+msgid "must be greater than or equal to %{number}"
+msgstr ""
+
+msgid "must be equal to %{number}"
+msgstr ""
diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot
new file mode 100644
index 0000000..39a220b
--- /dev/null
+++ b/priv/gettext/errors.pot
@@ -0,0 +1,95 @@
+## This is a PO Template file.
+##
+## `msgid`s here are often extracted from source code.
+## Add new translations manually only if they're dynamic
+## translations that can't be statically extracted.
+##
+## Run `mix gettext.extract` to bring this file up to
+## date. Leave `msgstr`s empty as changing them here has no
+## effect: edit them in PO (`.po`) files instead.
+
+## From Ecto.Changeset.cast/4
+msgid "can't be blank"
+msgstr ""
+
+## From Ecto.Changeset.unique_constraint/3
+msgid "has already been taken"
+msgstr ""
+
+## From Ecto.Changeset.put_change/3
+msgid "is invalid"
+msgstr ""
+
+## From Ecto.Changeset.validate_acceptance/3
+msgid "must be accepted"
+msgstr ""
+
+## From Ecto.Changeset.validate_format/3
+msgid "has invalid format"
+msgstr ""
+
+## From Ecto.Changeset.validate_subset/3
+msgid "has an invalid entry"
+msgstr ""
+
+## From Ecto.Changeset.validate_exclusion/3
+msgid "is reserved"
+msgstr ""
+
+## From Ecto.Changeset.validate_confirmation/3
+msgid "does not match confirmation"
+msgstr ""
+
+## From Ecto.Changeset.no_assoc_constraint/3
+msgid "is still associated with this entry"
+msgstr ""
+
+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)"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "should have at least %{count} item(s)"
+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)"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "should have at most %{count} item(s)"
+msgid_plural "should have at most %{count} item(s)"
+msgstr[0] ""
+msgstr[1] ""
+
+## From Ecto.Changeset.validate_number/3
+msgid "must be less than %{number}"
+msgstr ""
+
+msgid "must be greater than %{number}"
+msgstr ""
+
+msgid "must be less than or equal to %{number}"
+msgstr ""
+
+msgid "must be greater than or equal to %{number}"
+msgstr ""
+
+msgid "must be equal to %{number}"
+msgstr ""
diff --git a/priv/repo/migrations/.formatter.exs b/priv/repo/migrations/.formatter.exs
new file mode 100644
index 0000000..49f9151
--- /dev/null
+++ b/priv/repo/migrations/.formatter.exs
@@ -0,0 +1,4 @@
+[
+ import_deps: [:ecto_sql],
+ inputs: ["*.exs"]
+]
diff --git a/priv/repo/migrations/20220405070421_create_rooms.exs b/priv/repo/migrations/20220405070421_create_rooms.exs
new file mode 100644
index 0000000..308e3f8
--- /dev/null
+++ b/priv/repo/migrations/20220405070421_create_rooms.exs
@@ -0,0 +1,13 @@
+defmodule Aggiedit.Repo.Migrations.CreateRooms do
+ use Ecto.Migration
+
+ def change do
+ create table(:rooms) do
+ add :domain, :string, null: false
+
+ timestamps()
+ end
+
+ create unique_index(:rooms, [:domain])
+ end
+end
diff --git a/priv/repo/migrations/20220405071636_create_users_auth_tables.exs b/priv/repo/migrations/20220405071636_create_users_auth_tables.exs
new file mode 100644
index 0000000..06bde64
--- /dev/null
+++ b/priv/repo/migrations/20220405071636_create_users_auth_tables.exs
@@ -0,0 +1,30 @@
+defmodule Aggiedit.Repo.Migrations.CreateUsersAuthTables do
+ use Ecto.Migration
+
+ def change do
+ execute "CREATE EXTENSION IF NOT EXISTS citext", ""
+
+ create table(:users) do
+ add :email, :citext, null: false
+ add :username, :string, null: false
+ add :hashed_password, :string, null: false
+ add :confirmed_at, :naive_datetime
+ add :role, :string
+ add :room_id, references(:rooms, on_delete: :delete_all)
+ timestamps()
+ end
+
+ create unique_index(:users, [:email])
+
+ create table(:users_tokens) do
+ add :user_id, references(:users, on_delete: :delete_all), null: false
+ add :token, :binary, null: false
+ add :context, :string, null: false
+ add :sent_to, :string
+ timestamps(updated_at: false)
+ end
+
+ create index(:users_tokens, [:user_id])
+ create unique_index(:users_tokens, [:context, :token])
+ end
+end \ No newline at end of file
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs
new file mode 100644
index 0000000..77639be
--- /dev/null
+++ b/priv/repo/seeds.exs
@@ -0,0 +1,11 @@
+# Script for populating the database. You can run it as:
+#
+# mix run priv/repo/seeds.exs
+#
+# Inside the script, you can read and write to any of your
+# repositories directly:
+#
+# Aggiedit.Repo.insert!(%Aggiedit.SomeSchema{})
+#
+# We recommend using the bang functions (`insert!`, `update!`
+# and so on) as they will fail if something goes wrong. \ No newline at end of file
diff --git a/proposal.md b/proposal.md
new file mode 100644
index 0000000..f84206a
--- /dev/null
+++ b/proposal.md
@@ -0,0 +1,30 @@
+# Aggiedit
+Aggiedit will be a Reddit clone for university students and businesses with custom domains to share memes or discuss things. When a user registers with a new domain, if a "subaggie" doesn't already exist for that domain, it will be created. Otherwise, they have joined that "subaggie".
+
+## Challenges
+* Learning LiveView (I have experience with Phoenix, but want to checkout the real-time aspect of it)
+* Email sending for authentcation (I've looked into sendgrid for my own domain)
+
+## Requirements
+* User authentication
+ This app uses user authentication and verification to determine if someone actually belongs to a domain, in addition to providing an owner on each post.
+* Database
+ I will need to store rooms, posts, comments, uploads, etc. in a database and be able to make queries to it.
+* Publication
+ This should be relatively easy since I can just copy most of my Docker stuff from my personal Phoenix projects.
+* Usefulness
+ This app will be useful in spreading discussion semi-anonymously.
+
+## Timeline
+| Task | Time |
+|----------------------------------------------------------------------------|----------|
+| Setting up project with basic authentication from `mix phx.gen.auth` | 1 hour |
+| Adding email verification with SendGrid | 1 hour |
+| Creating real-time post timeline and models | 2 hours |
+| Adding rooms for each domain; limiting posts to users within that domain | 3 hours |
+| Adding static image uploads | 1 hour |
+| Associating uploads in db with a post | 1 hour |
+| UI cleanup | 4 hours |
+| Deployment | 2 hours |
+
+Total: 15 hours
diff --git a/test/aggiedit/accounts_test.exs b/test/aggiedit/accounts_test.exs
new file mode 100644
index 0000000..0c1f38f
--- /dev/null
+++ b/test/aggiedit/accounts_test.exs
@@ -0,0 +1,508 @@
+defmodule Aggiedit.AccountsTest do
+ use Aggiedit.DataCase
+
+ alias Aggiedit.Accounts
+
+ import Aggiedit.AccountsFixtures
+ alias Aggiedit.Accounts.{User, UserToken}
+
+ describe "get_user_by_email/1" do
+ test "does not return the user if the email does not exist" do
+ refute Accounts.get_user_by_email("unknown@example.com")
+ end
+
+ test "returns the user if the email exists" do
+ %{id: id} = user = user_fixture()
+ assert %User{id: ^id} = Accounts.get_user_by_email(user.email)
+ end
+ end
+
+ describe "get_user_by_email_and_password/2" do
+ test "does not return the user if the email does not exist" do
+ refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!")
+ end
+
+ test "does not return the user if the password is not valid" do
+ user = user_fixture()
+ refute Accounts.get_user_by_email_and_password(user.email, "invalid")
+ end
+
+ test "returns the user if the email and password are valid" do
+ %{id: id} = user = user_fixture()
+
+ assert %User{id: ^id} =
+ Accounts.get_user_by_email_and_password(user.email, valid_user_password())
+ end
+ end
+
+ describe "get_user!/1" do
+ test "raises if id is invalid" do
+ assert_raise Ecto.NoResultsError, fn ->
+ Accounts.get_user!(-1)
+ end
+ end
+
+ test "returns the user with the given id" do
+ %{id: id} = user = user_fixture()
+ assert %User{id: ^id} = Accounts.get_user!(user.id)
+ end
+ end
+
+ describe "register_user/1" do
+ test "requires email and password to be set" do
+ {:error, changeset} = Accounts.register_user(%{})
+
+ assert %{
+ password: ["can't be blank"],
+ email: ["can't be blank"]
+ } = errors_on(changeset)
+ end
+
+ test "validates email and password when given" do
+ {:error, changeset} = Accounts.register_user(%{email: "not valid", password: "not valid"})
+
+ assert %{
+ email: ["must have the @ sign and no spaces"],
+ password: ["should be at least 12 character(s)"]
+ } = errors_on(changeset)
+ end
+
+ test "validates maximum values for email and password for security" do
+ too_long = String.duplicate("db", 100)
+ {:error, changeset} = Accounts.register_user(%{email: too_long, password: too_long})
+ assert "should be at most 160 character(s)" in errors_on(changeset).email
+ assert "should be at most 72 character(s)" in errors_on(changeset).password
+ end
+
+ test "validates email uniqueness" do
+ %{email: email} = user_fixture()
+ {:error, changeset} = Accounts.register_user(%{email: email})
+ assert "has already been taken" in errors_on(changeset).email
+
+ # Now try with the upper cased email too, to check that email case is ignored.
+ {:error, changeset} = Accounts.register_user(%{email: String.upcase(email)})
+ assert "has already been taken" in errors_on(changeset).email
+ end
+
+ test "registers users with a hashed password" do
+ email = unique_user_email()
+ {:ok, user} = Accounts.register_user(valid_user_attributes(email: email))
+ assert user.email == email
+ assert is_binary(user.hashed_password)
+ assert is_nil(user.confirmed_at)
+ assert is_nil(user.password)
+ end
+ end
+
+ describe "change_user_registration/2" do
+ test "returns a changeset" do
+ assert %Ecto.Changeset{} = changeset = Accounts.change_user_registration(%User{})
+ assert changeset.required == [:password, :email]
+ end
+
+ test "allows fields to be set" do
+ email = unique_user_email()
+ password = valid_user_password()
+
+ changeset =
+ Accounts.change_user_registration(
+ %User{},
+ valid_user_attributes(email: email, password: password)
+ )
+
+ assert changeset.valid?
+ assert get_change(changeset, :email) == email
+ assert get_change(changeset, :password) == password
+ assert is_nil(get_change(changeset, :hashed_password))
+ end
+ end
+
+ describe "change_user_email/2" do
+ test "returns a user changeset" do
+ assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{})
+ assert changeset.required == [:email]
+ end
+ end
+
+ describe "apply_user_email/3" do
+ setup do
+ %{user: user_fixture()}
+ end
+
+ test "requires email to change", %{user: user} do
+ {:error, changeset} = Accounts.apply_user_email(user, valid_user_password(), %{})
+ assert %{email: ["did not change"]} = errors_on(changeset)
+ end
+
+ test "validates email", %{user: user} do
+ {:error, changeset} =
+ Accounts.apply_user_email(user, valid_user_password(), %{email: "not valid"})
+
+ assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset)
+ end
+
+ test "validates maximum value for email for security", %{user: user} do
+ too_long = String.duplicate("db", 100)
+
+ {:error, changeset} =
+ Accounts.apply_user_email(user, valid_user_password(), %{email: too_long})
+
+ assert "should be at most 160 character(s)" in errors_on(changeset).email
+ end
+
+ test "validates email uniqueness", %{user: user} do
+ %{email: email} = user_fixture()
+
+ {:error, changeset} =
+ Accounts.apply_user_email(user, valid_user_password(), %{email: email})
+
+ assert "has already been taken" in errors_on(changeset).email
+ end
+
+ test "validates current password", %{user: user} do
+ {:error, changeset} =
+ Accounts.apply_user_email(user, "invalid", %{email: unique_user_email()})
+
+ assert %{current_password: ["is not valid"]} = errors_on(changeset)
+ end
+
+ test "applies the email without persisting it", %{user: user} do
+ email = unique_user_email()
+ {:ok, user} = Accounts.apply_user_email(user, valid_user_password(), %{email: email})
+ assert user.email == email
+ assert Accounts.get_user!(user.id).email != email
+ end
+ end
+
+ describe "deliver_update_email_instructions/3" do
+ setup do
+ %{user: user_fixture()}
+ end
+
+ test "sends token through notification", %{user: user} do
+ token =
+ extract_user_token(fn url ->
+ Accounts.deliver_update_email_instructions(user, "current@example.com", url)
+ end)
+
+ {:ok, token} = Base.url_decode64(token, padding: false)
+ assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
+ assert user_token.user_id == user.id
+ assert user_token.sent_to == user.email
+ assert user_token.context == "change:current@example.com"
+ end
+ end
+
+ describe "update_user_email/2" do
+ setup do
+ user = user_fixture()
+ email = unique_user_email()
+
+ token =
+ extract_user_token(fn url ->
+ Accounts.deliver_update_email_instructions(%{user | email: email}, user.email, url)
+ end)
+
+ %{user: user, token: token, email: email}
+ end
+
+ test "updates the email with a valid token", %{user: user, token: token, email: email} do
+ assert Accounts.update_user_email(user, token) == :ok
+ changed_user = Repo.get!(User, user.id)
+ assert changed_user.email != user.email
+ assert changed_user.email == email
+ assert changed_user.confirmed_at
+ assert changed_user.confirmed_at != user.confirmed_at
+ refute Repo.get_by(UserToken, user_id: user.id)
+ end
+
+ test "does not update email with invalid token", %{user: user} do
+ assert Accounts.update_user_email(user, "oops") == :error
+ assert Repo.get!(User, user.id).email == user.email
+ assert Repo.get_by(UserToken, user_id: user.id)
+ end
+
+ test "does not update email if user email changed", %{user: user, token: token} do
+ assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) == :error
+ assert Repo.get!(User, user.id).email == user.email
+ assert Repo.get_by(UserToken, user_id: user.id)
+ end
+
+ test "does not update email if token expired", %{user: user, token: token} do
+ {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
+ assert Accounts.update_user_email(user, token) == :error
+ assert Repo.get!(User, user.id).email == user.email
+ assert Repo.get_by(UserToken, user_id: user.id)
+ end
+ end
+
+ describe "change_user_password/2" do
+ test "returns a user changeset" do
+ assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{})
+ assert changeset.required == [:password]
+ end
+
+ test "allows fields to be set" do
+ changeset =
+ Accounts.change_user_password(%User{}, %{
+ "password" => "new valid password"
+ })
+
+ assert changeset.valid?
+ assert get_change(changeset, :password) == "new valid password"
+ assert is_nil(get_change(changeset, :hashed_password))
+ end
+ end
+
+ describe "update_user_password/3" do
+ setup do
+ %{user: user_fixture()}
+ end
+
+ test "validates password", %{user: user} do
+ {:error, changeset} =
+ Accounts.update_user_password(user, valid_user_password(), %{
+ password: "not valid",
+ password_confirmation: "another"
+ })
+
+ assert %{
+ password: ["should be at least 12 character(s)"],
+ password_confirmation: ["does not match password"]
+ } = errors_on(changeset)
+ end
+
+ test "validates maximum values for password for security", %{user: user} do
+ too_long = String.duplicate("db", 100)
+
+ {:error, changeset} =
+ Accounts.update_user_password(user, valid_user_password(), %{password: too_long})
+
+ assert "should be at most 72 character(s)" in errors_on(changeset).password
+ end
+
+ test "validates current password", %{user: user} do
+ {:error, changeset} =
+ Accounts.update_user_password(user, "invalid", %{password: valid_user_password()})
+
+ assert %{current_password: ["is not valid"]} = errors_on(changeset)
+ end
+
+ test "updates the password", %{user: user} do
+ {:ok, user} =
+ Accounts.update_user_password(user, valid_user_password(), %{
+ password: "new valid password"
+ })
+
+ assert is_nil(user.password)
+ assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
+ end
+
+ test "deletes all tokens for the given user", %{user: user} do
+ _ = Accounts.generate_user_session_token(user)
+
+ {:ok, _} =
+ Accounts.update_user_password(user, valid_user_password(), %{
+ password: "new valid password"
+ })
+
+ refute Repo.get_by(UserToken, user_id: user.id)
+ end
+ end
+
+ describe "generate_user_session_token/1" do
+ setup do
+ %{user: user_fixture()}
+ end
+
+ test "generates a token", %{user: user} do
+ token = Accounts.generate_user_session_token(user)
+ assert user_token = Repo.get_by(UserToken, token: token)
+ assert user_token.context == "session"
+
+ # Creating the same token for another user should fail
+ assert_raise Ecto.ConstraintError, fn ->
+ Repo.insert!(%UserToken{
+ token: user_token.token,
+ user_id: user_fixture().id,
+ context: "session"
+ })
+ end
+ end
+ end
+
+ describe "get_user_by_session_token/1" do
+ setup do
+ user = user_fixture()
+ token = Accounts.generate_user_session_token(user)
+ %{user: user, token: token}
+ end
+
+ test "returns user by token", %{user: user, token: token} do
+ assert session_user = Accounts.get_user_by_session_token(token)
+ assert session_user.id == user.id
+ end
+
+ test "does not return user for invalid token" do
+ refute Accounts.get_user_by_session_token("oops")
+ end
+
+ test "does not return user for expired token", %{token: token} do
+ {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
+ refute Accounts.get_user_by_session_token(token)
+ end
+ end
+
+ describe "delete_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
+ refute Accounts.get_user_by_session_token(token)
+ end
+ end
+
+ describe "deliver_user_confirmation_instructions/2" do
+ setup do
+ %{user: user_fixture()}
+ end
+
+ test "sends token through notification", %{user: user} do
+ token =
+ extract_user_token(fn url ->
+ Accounts.deliver_user_confirmation_instructions(user, url)
+ end)
+
+ {:ok, token} = Base.url_decode64(token, padding: false)
+ assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
+ assert user_token.user_id == user.id
+ assert user_token.sent_to == user.email
+ assert user_token.context == "confirm"
+ end
+ end
+
+ describe "confirm_user/1" do
+ setup do
+ user = user_fixture()
+
+ token =
+ extract_user_token(fn url ->
+ Accounts.deliver_user_confirmation_instructions(user, url)
+ end)
+
+ %{user: user, token: token}
+ end
+
+ test "confirms the email with a valid token", %{user: user, token: token} do
+ assert {:ok, confirmed_user} = Accounts.confirm_user(token)
+ assert confirmed_user.confirmed_at
+ assert confirmed_user.confirmed_at != user.confirmed_at
+ assert Repo.get!(User, user.id).confirmed_at
+ refute Repo.get_by(UserToken, user_id: user.id)
+ end
+
+ test "does not confirm with invalid token", %{user: user} do
+ assert Accounts.confirm_user("oops") == :error
+ refute Repo.get!(User, user.id).confirmed_at
+ assert Repo.get_by(UserToken, user_id: user.id)
+ end
+
+ test "does not confirm email if token expired", %{user: user, token: token} do
+ {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
+ assert Accounts.confirm_user(token) == :error
+ refute Repo.get!(User, user.id).confirmed_at
+ assert Repo.get_by(UserToken, user_id: user.id)
+ end
+ end
+
+ describe "deliver_user_reset_password_instructions/2" do
+ setup do
+ %{user: user_fixture()}
+ end
+
+ test "sends token through notification", %{user: user} do
+ token =
+ extract_user_token(fn url ->
+ Accounts.deliver_user_reset_password_instructions(user, url)
+ end)
+
+ {:ok, token} = Base.url_decode64(token, padding: false)
+ assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
+ assert user_token.user_id == user.id
+ assert user_token.sent_to == user.email
+ assert user_token.context == "reset_password"
+ end
+ end
+
+ describe "get_user_by_reset_password_token/1" do
+ setup do
+ user = user_fixture()
+
+ token =
+ extract_user_token(fn url ->
+ Accounts.deliver_user_reset_password_instructions(user, url)
+ end)
+
+ %{user: user, token: token}
+ end
+
+ test "returns the user with valid token", %{user: %{id: id}, token: token} do
+ assert %User{id: ^id} = Accounts.get_user_by_reset_password_token(token)
+ assert Repo.get_by(UserToken, user_id: id)
+ end
+
+ test "does not return the user with invalid token", %{user: user} do
+ refute Accounts.get_user_by_reset_password_token("oops")
+ assert Repo.get_by(UserToken, user_id: user.id)
+ end
+
+ test "does not return the user if token expired", %{user: user, token: token} do
+ {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
+ refute Accounts.get_user_by_reset_password_token(token)
+ assert Repo.get_by(UserToken, user_id: user.id)
+ end
+ end
+
+ describe "reset_user_password/2" do
+ setup do
+ %{user: user_fixture()}
+ end
+
+ test "validates password", %{user: user} do
+ {:error, changeset} =
+ Accounts.reset_user_password(user, %{
+ password: "not valid",
+ password_confirmation: "another"
+ })
+
+ assert %{
+ password: ["should be at least 12 character(s)"],
+ password_confirmation: ["does not match password"]
+ } = errors_on(changeset)
+ end
+
+ test "validates maximum values for password for security", %{user: user} do
+ too_long = String.duplicate("db", 100)
+ {:error, changeset} = Accounts.reset_user_password(user, %{password: too_long})
+ assert "should be at most 72 character(s)" in errors_on(changeset).password
+ end
+
+ test "updates the password", %{user: user} do
+ {:ok, updated_user} = Accounts.reset_user_password(user, %{password: "new valid password"})
+ assert is_nil(updated_user.password)
+ assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
+ end
+
+ test "deletes all tokens for the given user", %{user: user} do
+ _ = Accounts.generate_user_session_token(user)
+ {:ok, _} = Accounts.reset_user_password(user, %{password: "new valid password"})
+ refute Repo.get_by(UserToken, user_id: user.id)
+ end
+ end
+
+ describe "inspect/2" do
+ test "does not include password" do
+ refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
+ end
+ end
+end
diff --git a/test/aggiedit/rooms_test.exs b/test/aggiedit/rooms_test.exs
new file mode 100644
index 0000000..2a270e6
--- /dev/null
+++ b/test/aggiedit/rooms_test.exs
@@ -0,0 +1,59 @@
+defmodule Aggiedit.RoomsTest do
+ use Aggiedit.DataCase
+
+ alias Aggiedit.Rooms
+
+ describe "rooms" do
+ alias Aggiedit.Rooms.Room
+
+ import Aggiedit.RoomsFixtures
+
+ @invalid_attrs %{domain: nil}
+
+ test "list_rooms/0 returns all rooms" do
+ room = room_fixture()
+ assert Rooms.list_rooms() == [room]
+ end
+
+ test "get_room!/1 returns the room with given id" do
+ room = room_fixture()
+ assert Rooms.get_room!(room.id) == room
+ end
+
+ test "create_room/1 with valid data creates a room" do
+ valid_attrs = %{domain: "some domain"}
+
+ assert {:ok, %Room{} = room} = Rooms.create_room(valid_attrs)
+ assert room.domain == "some domain"
+ end
+
+ test "create_room/1 with invalid data returns error changeset" do
+ assert {:error, %Ecto.Changeset{}} = Rooms.create_room(@invalid_attrs)
+ end
+
+ test "update_room/2 with valid data updates the room" do
+ room = room_fixture()
+ update_attrs = %{domain: "some updated domain"}
+
+ assert {:ok, %Room{} = room} = Rooms.update_room(room, update_attrs)
+ assert room.domain == "some updated domain"
+ end
+
+ test "update_room/2 with invalid data returns error changeset" do
+ room = room_fixture()
+ assert {:error, %Ecto.Changeset{}} = Rooms.update_room(room, @invalid_attrs)
+ assert room == Rooms.get_room!(room.id)
+ end
+
+ test "delete_room/1 deletes the room" do
+ room = room_fixture()
+ assert {:ok, %Room{}} = Rooms.delete_room(room)
+ assert_raise Ecto.NoResultsError, fn -> Rooms.get_room!(room.id) end
+ end
+
+ test "change_room/1 returns a room changeset" do
+ room = room_fixture()
+ assert %Ecto.Changeset{} = Rooms.change_room(room)
+ end
+ end
+end
diff --git a/test/aggiedit_web/controllers/page_controller_test.exs b/test/aggiedit_web/controllers/page_controller_test.exs
new file mode 100644
index 0000000..4819f82
--- /dev/null
+++ b/test/aggiedit_web/controllers/page_controller_test.exs
@@ -0,0 +1,8 @@
+defmodule AggieditWeb.PageControllerTest do
+ use AggieditWeb.ConnCase
+
+ test "GET /", %{conn: conn} do
+ conn = get(conn, "/")
+ assert html_response(conn, 200) =~ "Welcome to Phoenix!"
+ end
+end
diff --git a/test/aggiedit_web/controllers/user_auth_test.exs b/test/aggiedit_web/controllers/user_auth_test.exs
new file mode 100644
index 0000000..6b3748e
--- /dev/null
+++ b/test/aggiedit_web/controllers/user_auth_test.exs
@@ -0,0 +1,170 @@
+defmodule AggieditWeb.UserAuthTest do
+ use AggieditWeb.ConnCase, async: true
+
+ alias Aggiedit.Accounts
+ alias AggieditWeb.UserAuth
+ import Aggiedit.AccountsFixtures
+
+ @remember_me_cookie "_aggiedit_web_user_remember_me"
+
+ setup %{conn: conn} do
+ conn =
+ conn
+ |> Map.replace!(:secret_key_base, AggieditWeb.Endpoint.config(:secret_key_base))
+ |> init_test_session(%{})
+
+ %{user: user_fixture(), conn: conn}
+ end
+
+ describe "log_in_user/3" do
+ test "stores the user token in the session", %{conn: conn, user: user} 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 Accounts.get_user_by_session_token(token)
+ end
+
+ test "clears everything previously stored in the session", %{conn: conn, user: user} do
+ conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user)
+ refute get_session(conn, :to_be_removed)
+ end
+
+ test "redirects to the configured path", %{conn: conn, user: user} do
+ conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user)
+ assert redirected_to(conn) == "/hello"
+ end
+
+ test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do
+ conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
+ assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie]
+
+ assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
+ assert signed_token != get_session(conn, :user_token)
+ assert max_age == 5_184_000
+ end
+ end
+
+ describe "logout_user/1" do
+ test "erases session and cookies", %{conn: conn, user: user} do
+ user_token = Accounts.generate_user_session_token(user)
+
+ conn =
+ conn
+ |> put_session(:user_token, user_token)
+ |> put_req_cookie(@remember_me_cookie, user_token)
+ |> fetch_cookies()
+ |> UserAuth.log_out_user()
+
+ 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) == "/"
+ refute Accounts.get_user_by_session_token(user_token)
+ end
+
+ test "broadcasts to the given live_socket_id", %{conn: conn} do
+ live_socket_id = "users_sessions:abcdef-token"
+ AggieditWeb.Endpoint.subscribe(live_socket_id)
+
+ conn
+ |> put_session(:live_socket_id, live_socket_id)
+ |> UserAuth.log_out_user()
+
+ assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id}
+ end
+
+ test "works even if user is already logged out", %{conn: conn} 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) == "/"
+ end
+ end
+
+ describe "fetch_current_user/2" do
+ test "authenticates user from session", %{conn: conn, user: user} do
+ user_token = Accounts.generate_user_session_token(user)
+ conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([])
+ assert conn.assigns.current_user.id == user.id
+ end
+
+ test "authenticates user from cookies", %{conn: conn, user: user} do
+ logged_in_conn =
+ conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
+
+ user_token = logged_in_conn.cookies[@remember_me_cookie]
+ %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
+
+ conn =
+ conn
+ |> 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
+ end
+
+ test "does not authenticate if data is missing", %{conn: conn, user: user} do
+ _ = Accounts.generate_user_session_token(user)
+ conn = UserAuth.fetch_current_user(conn, [])
+ refute get_session(conn, :user_token)
+ refute conn.assigns.current_user
+ 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) == "/"
+ end
+
+ test "does not redirect if user is not authenticated", %{conn: conn} do
+ conn = UserAuth.redirect_if_user_is_authenticated(conn, [])
+ refute conn.halted
+ refute conn.status
+ end
+ end
+
+ describe "require_authenticated_user/2" 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."
+ end
+
+ test "stores the path to redirect to on GET", %{conn: conn} do
+ halted_conn =
+ %{conn | path_info: ["foo"], query_string: ""}
+ |> fetch_flash()
+ |> UserAuth.require_authenticated_user([])
+
+ assert halted_conn.halted
+ assert get_session(halted_conn, :user_return_to) == "/foo"
+
+ halted_conn =
+ %{conn | path_info: ["foo"], query_string: "bar=baz"}
+ |> fetch_flash()
+ |> UserAuth.require_authenticated_user([])
+
+ assert halted_conn.halted
+ assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz"
+
+ halted_conn =
+ %{conn | path_info: ["foo"], query_string: "bar", method: "POST"}
+ |> fetch_flash()
+ |> UserAuth.require_authenticated_user([])
+
+ assert halted_conn.halted
+ refute get_session(halted_conn, :user_return_to)
+ end
+
+ test "does not redirect if user is authenticated", %{conn: conn, user: user} do
+ conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([])
+ refute conn.halted
+ refute conn.status
+ end
+ end
+end
diff --git a/test/aggiedit_web/controllers/user_confirmation_controller_test.exs b/test/aggiedit_web/controllers/user_confirmation_controller_test.exs
new file mode 100644
index 0000000..4209ca1
--- /dev/null
+++ b/test/aggiedit_web/controllers/user_confirmation_controller_test.exs
@@ -0,0 +1,105 @@
+defmodule AggieditWeb.UserConfirmationControllerTest do
+ use AggieditWeb.ConnCase, async: true
+
+ alias Aggiedit.Accounts
+ alias Aggiedit.Repo
+ import Aggiedit.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 =~ "<h1>Resend confirmation instructions</h1>"
+ 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 =~ "<h1>Confirm account</h1>"
+
+ 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/aggiedit_web/controllers/user_registration_controller_test.exs b/test/aggiedit_web/controllers/user_registration_controller_test.exs
new file mode 100644
index 0000000..d323828
--- /dev/null
+++ b/test/aggiedit_web/controllers/user_registration_controller_test.exs
@@ -0,0 +1,54 @@
+defmodule AggieditWeb.UserRegistrationControllerTest do
+ use AggieditWeb.ConnCase, async: true
+
+ import Aggiedit.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 =~ "<h1>Register</h1>"
+ assert response =~ "Log in</a>"
+ assert response =~ "Register</a>"
+ 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</a>"
+ assert response =~ "Log out</a>"
+ 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 =~ "<h1>Register</h1>"
+ assert response =~ "must have the @ sign and no spaces"
+ assert response =~ "should be at least 12 character"
+ end
+ end
+end
diff --git a/test/aggiedit_web/controllers/user_reset_password_controller_test.exs b/test/aggiedit_web/controllers/user_reset_password_controller_test.exs
new file mode 100644
index 0000000..ca57896
--- /dev/null
+++ b/test/aggiedit_web/controllers/user_reset_password_controller_test.exs
@@ -0,0 +1,113 @@
+defmodule AggieditWeb.UserResetPasswordControllerTest do
+ use AggieditWeb.ConnCase, async: true
+
+ alias Aggiedit.Accounts
+ alias Aggiedit.Repo
+ import Aggiedit.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 =~ "<h1>Forgot your password?</h1>"
+ 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) =~ "<h1>Reset password</h1>"
+ 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 =~ "<h1>Reset password</h1>"
+ 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/aggiedit_web/controllers/user_session_controller_test.exs b/test/aggiedit_web/controllers/user_session_controller_test.exs
new file mode 100644
index 0000000..5e3f7da
--- /dev/null
+++ b/test/aggiedit_web/controllers/user_session_controller_test.exs
@@ -0,0 +1,98 @@
+defmodule AggieditWeb.UserSessionControllerTest do
+ use AggieditWeb.ConnCase, async: true
+
+ import Aggiedit.AccountsFixtures
+
+ setup 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 =~ "<h1>Log in</h1>"
+ assert response =~ "Register</a>"
+ assert response =~ "Forgot your password?</a>"
+ 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), %{
+ "user" => %{"email" => user.email, "password" => valid_user_password()}
+ })
+
+ 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 =~ user.email
+ assert response =~ "Settings</a>"
+ assert response =~ "Log out</a>"
+ end
+
+ test "logs the user in with remember me", %{conn: conn, user: user} do
+ conn =
+ post(conn, Routes.user_session_path(conn, :create), %{
+ "user" => %{
+ "email" => user.email,
+ "password" => valid_user_password(),
+ "remember_me" => "true"
+ }
+ })
+
+ assert conn.resp_cookies["_aggiedit_web_user_remember_me"]
+ assert redirected_to(conn) == "/"
+ 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), %{
+ "user" => %{
+ "email" => user.email,
+ "password" => valid_user_password()
+ }
+ })
+
+ assert redirected_to(conn) == "/foo/bar"
+ end
+
+ test "emits error message with invalid credentials", %{conn: conn, user: user} do
+ conn =
+ post(conn, Routes.user_session_path(conn, :create), %{
+ "user" => %{"email" => user.email, "password" => "invalid_password"}
+ })
+
+ response = html_response(conn, 200)
+ assert response =~ "<h1>Log in</h1>"
+ assert response =~ "Invalid email or password"
+ 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) == "/"
+ refute get_session(conn, :user_token)
+ assert get_flash(conn, :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) == "/"
+ refute get_session(conn, :user_token)
+ assert get_flash(conn, :info) =~ "Logged out successfully"
+ end
+ end
+end
diff --git a/test/aggiedit_web/controllers/user_settings_controller_test.exs b/test/aggiedit_web/controllers/user_settings_controller_test.exs
new file mode 100644
index 0000000..f5d4bdc
--- /dev/null
+++ b/test/aggiedit_web/controllers/user_settings_controller_test.exs
@@ -0,0 +1,129 @@
+defmodule AggieditWeb.UserSettingsControllerTest do
+ use AggieditWeb.ConnCase, async: true
+
+ alias Aggiedit.Accounts
+ import Aggiedit.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 =~ "<h1>Settings</h1>"
+ 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 =~ "<h1>Settings</h1>"
+ 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 =~ "<h1>Settings</h1>"
+ 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/aggiedit_web/views/error_view_test.exs b/test/aggiedit_web/views/error_view_test.exs
new file mode 100644
index 0000000..32ef690
--- /dev/null
+++ b/test/aggiedit_web/views/error_view_test.exs
@@ -0,0 +1,14 @@
+defmodule AggieditWeb.ErrorViewTest do
+ use AggieditWeb.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(AggieditWeb.ErrorView, "404.html", []) == "Not Found"
+ end
+
+ test "renders 500.html" do
+ assert render_to_string(AggieditWeb.ErrorView, "500.html", []) == "Internal Server Error"
+ end
+end
diff --git a/test/aggiedit_web/views/layout_view_test.exs b/test/aggiedit_web/views/layout_view_test.exs
new file mode 100644
index 0000000..0311580
--- /dev/null
+++ b/test/aggiedit_web/views/layout_view_test.exs
@@ -0,0 +1,8 @@
+defmodule AggieditWeb.LayoutViewTest do
+ use AggieditWeb.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/aggiedit_web/views/page_view_test.exs b/test/aggiedit_web/views/page_view_test.exs
new file mode 100644
index 0000000..0c63680
--- /dev/null
+++ b/test/aggiedit_web/views/page_view_test.exs
@@ -0,0 +1,3 @@
+defmodule AggieditWeb.PageViewTest do
+ use AggieditWeb.ConnCase, async: true
+end
diff --git a/test/support/channel_case.ex b/test/support/channel_case.ex
new file mode 100644
index 0000000..3afe09b
--- /dev/null
+++ b/test/support/channel_case.ex
@@ -0,0 +1,36 @@
+defmodule AggieditWeb.ChannelCase do
+ @moduledoc """
+ This module defines the test case to be used by
+ channel tests.
+
+ Such tests rely on `Phoenix.ChannelTest` and also
+ import other functionality to make it easier
+ to build common data structures and query the data layer.
+
+ Finally, if the test case interacts with the database,
+ we enable the SQL sandbox, so changes done to the database
+ are reverted at the end of every test. If you are using
+ PostgreSQL, you can even run database tests asynchronously
+ by setting `use AggieditWeb.ChannelCase, async: true`, although
+ this option is not recommended for other databases.
+ """
+
+ use ExUnit.CaseTemplate
+
+ using do
+ quote do
+ # Import conveniences for testing with channels
+ import Phoenix.ChannelTest
+ import AggieditWeb.ChannelCase
+
+ # The default endpoint for testing
+ @endpoint AggieditWeb.Endpoint
+ end
+ end
+
+ setup tags do
+ pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Aggiedit.Repo, shared: not tags[:async])
+ on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
+ :ok
+ end
+end
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
new file mode 100644
index 0000000..03f5170
--- /dev/null
+++ b/test/support/conn_case.ex
@@ -0,0 +1,65 @@
+defmodule AggieditWeb.ConnCase do
+ @moduledoc """
+ This module defines the test case to be used by
+ tests that require setting up a connection.
+
+ Such tests rely on `Phoenix.ConnTest` and also
+ import other functionality to make it easier
+ to build common data structures and query the data layer.
+
+ Finally, if the test case interacts with the database,
+ we enable the SQL sandbox, so changes done to the database
+ are reverted at the end of every test. If you are using
+ PostgreSQL, you can even run database tests asynchronously
+ by setting `use AggieditWeb.ConnCase, async: true`, although
+ this option is not recommended for other databases.
+ """
+
+ use ExUnit.CaseTemplate
+
+ using do
+ quote do
+ # Import conveniences for testing with connections
+ import Plug.Conn
+ import Phoenix.ConnTest
+ import AggieditWeb.ConnCase
+
+ alias AggieditWeb.Router.Helpers, as: Routes
+
+ # The default endpoint for testing
+ @endpoint AggieditWeb.Endpoint
+ end
+ end
+
+ setup tags do
+ pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Aggiedit.Repo, shared: not tags[:async])
+ on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
+ {:ok, conn: Phoenix.ConnTest.build_conn()}
+ end
+
+ @doc """
+ Setup helper that registers and logs in users.
+
+ setup :register_and_log_in_user
+
+ It stores an updated connection and a registered user in the
+ test context.
+ """
+ def register_and_log_in_user(%{conn: conn}) do
+ user = Aggiedit.AccountsFixtures.user_fixture()
+ %{conn: log_in_user(conn, user), user: user}
+ end
+
+ @doc """
+ Logs the given `user` into the `conn`.
+
+ It returns an updated `conn`.
+ """
+ def log_in_user(conn, user) do
+ token = Aggiedit.Accounts.generate_user_session_token(user)
+
+ conn
+ |> Phoenix.ConnTest.init_test_session(%{})
+ |> Plug.Conn.put_session(:user_token, token)
+ end
+end
diff --git a/test/support/data_case.ex b/test/support/data_case.ex
new file mode 100644
index 0000000..7718d18
--- /dev/null
+++ b/test/support/data_case.ex
@@ -0,0 +1,51 @@
+defmodule Aggiedit.DataCase do
+ @moduledoc """
+ This module defines the setup for tests requiring
+ access to the application's data layer.
+
+ You may define functions here to be used as helpers in
+ your tests.
+
+ Finally, if the test case interacts with the database,
+ we enable the SQL sandbox, so changes done to the database
+ are reverted at the end of every test. If you are using
+ PostgreSQL, you can even run database tests asynchronously
+ by setting `use Aggiedit.DataCase, async: true`, although
+ this option is not recommended for other databases.
+ """
+
+ use ExUnit.CaseTemplate
+
+ using do
+ quote do
+ alias Aggiedit.Repo
+
+ import Ecto
+ import Ecto.Changeset
+ import Ecto.Query
+ import Aggiedit.DataCase
+ end
+ end
+
+ setup tags do
+ pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Aggiedit.Repo, shared: not tags[:async])
+ on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
+ :ok
+ end
+
+ @doc """
+ A helper that transforms changeset errors into a map of messages.
+
+ assert {:error, changeset} = Accounts.create_user(%{password: "short"})
+ assert "password is too short" in errors_on(changeset).password
+ assert %{password: ["password is too short"]} = errors_on(changeset)
+
+ """
+ def errors_on(changeset) do
+ Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
+ Regex.replace(~r"%{(\w+)}", message, fn _, key ->
+ opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
+ end)
+ end)
+ end
+end
diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex
new file mode 100644
index 0000000..0ce0fdf
--- /dev/null
+++ b/test/support/fixtures/accounts_fixtures.ex
@@ -0,0 +1,31 @@
+defmodule Aggiedit.AccountsFixtures do
+ @moduledoc """
+ This module defines test helpers for creating
+ entities via the `Aggiedit.Accounts` context.
+ """
+
+ def unique_user_email, do: "user#{System.unique_integer()}@example.com"
+ def valid_user_password, do: "hello world!"
+
+ def valid_user_attributes(attrs \\ %{}) do
+ Enum.into(attrs, %{
+ email: unique_user_email(),
+ password: valid_user_password()
+ })
+ end
+
+ def user_fixture(attrs \\ %{}) do
+ {:ok, user} =
+ attrs
+ |> valid_user_attributes()
+ |> Aggiedit.Accounts.register_user()
+
+ user
+ end
+
+ def extract_user_token(fun) do
+ {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]")
+ [_, token | _] = String.split(captured_email.text_body, "[TOKEN]")
+ token
+ end
+end
diff --git a/test/support/fixtures/rooms_fixtures.ex b/test/support/fixtures/rooms_fixtures.ex
new file mode 100644
index 0000000..fd5671e
--- /dev/null
+++ b/test/support/fixtures/rooms_fixtures.ex
@@ -0,0 +1,20 @@
+defmodule Aggiedit.RoomsFixtures do
+ @moduledoc """
+ This module defines test helpers for creating
+ entities via the `Aggiedit.Rooms` context.
+ """
+
+ @doc """
+ Generate a room.
+ """
+ def room_fixture(attrs \\ %{}) do
+ {:ok, room} =
+ attrs
+ |> Enum.into(%{
+ domain: "some domain"
+ })
+ |> Aggiedit.Rooms.create_room()
+
+ room
+ end
+end
diff --git a/test/test_helper.exs b/test/test_helper.exs
new file mode 100644
index 0000000..0321594
--- /dev/null
+++ b/test/test_helper.exs
@@ -0,0 +1,2 @@
+ExUnit.start()
+Ecto.Adapters.SQL.Sandbox.mode(Aggiedit.Repo, :manual)