├── test ├── js ├── test_helper.exs ├── todo_list_web │ ├── controllers │ │ ├── base_controller_test.exs │ │ ├── error_json_test.exs │ │ └── error_html_test.exs │ └── live │ │ ├── user_forgot_password_live_test.exs │ │ ├── user_confirmation_instructions_live_test.exs │ │ ├── user_confirmation_live_test.exs │ │ └── user_login_live_test.exs ├── loadtest │ └── k6 │ │ ├── index.js │ │ ├── todo-index.js │ │ ├── todo-create.js │ │ └── login.js ├── support │ ├── fixtures │ │ ├── accounts_fixtures.ex │ │ └── todos_fixtures.ex │ ├── helpers.ex │ └── data_case.ex └── todo_list │ └── todos_test.exs ├── assets ├── .prettierrc.json ├── .prettierignore ├── tests │ ├── e2e │ │ ├── .auth │ │ │ ├── .prettierignore │ │ │ └── .gitignore │ │ ├── support │ │ │ ├── setup │ │ │ │ ├── example.ts │ │ │ │ └── global.ts │ │ │ ├── constants.ts │ │ │ ├── teardown.ts │ │ │ ├── helpers.ts │ │ │ └── fixtures.ts │ │ ├── base │ │ │ ├── index │ │ │ │ ├── page.ts │ │ │ │ └── test.spec.ts │ │ │ └── page.ts │ │ └── accounts │ │ │ ├── login │ │ │ ├── test.spec.ts │ │ │ └── page.ts │ │ │ ├── logout │ │ │ ├── page.ts │ │ │ └── test.spec.ts │ │ │ └── register │ │ │ ├── test.spec.ts │ │ │ └── page.ts │ ├── unit │ │ ├── constants.spec.ts │ │ ├── support │ │ │ └── setup.ts │ │ └── README.md │ └── support │ │ ├── constants.ts │ │ └── helpers.ts ├── js │ ├── base │ │ └── hooks.ts │ ├── constants.ts │ ├── hooks.ts │ ├── helpers.ts │ ├── todos │ │ └── hooks.ts │ ├── alpine.ts │ └── init │ │ └── page.js ├── index.d.ts ├── .gitignore ├── vitest.config.ts ├── tsconfig.json ├── package.json ├── tailwind.config.cjs └── playwright.config.ts ├── support ├── containers │ ├── .env │ ├── backups │ │ └── postgres │ │ │ ├── .gitignore │ │ │ ├── backup-create-outside-container │ │ │ ├── backup-restore-outside-container │ │ │ ├── backup-restore-inside-container │ │ │ └── backup-create-inside-container │ ├── compose.phoenix-config-traefik-staging.yaml │ ├── networks │ │ ├── compose.phoenix-host.yaml │ │ ├── compose.postgres-host.yaml │ │ ├── compose.postgres-expose.yaml │ │ ├── compose.phoenix-expose.yaml │ │ └── compose.phoenix-traefik.yaml │ ├── compose.grafana.yaml │ ├── compose.phoenix-config-traefik-dev.yaml │ ├── compose.phoenix-postgres.yaml │ ├── compose.phoenix-config-traefik-prod.yaml │ ├── compose.prometheus.yaml │ ├── etc │ │ ├── dev │ │ │ ├── traefik.yml │ │ │ └── middleware.yml │ │ ├── prometheus.yml │ │ ├── prod │ │ │ ├── middleware.yml │ │ │ └── traefik.yml │ │ └── staging │ │ │ ├── middleware.yml │ │ │ └── traefik.yml │ ├── compose.postgres.yaml │ ├── compose.phoenix.yaml │ ├── compose.traefik.yaml │ ├── compose.traefik-config-dev.yaml │ ├── compose.traefik-config-prod.yaml │ ├── compose.traefik-config-staging.yaml │ └── Dockerfile.base ├── scripts │ ├── loadtest-k6 │ ├── loadtest │ ├── test-js │ ├── test-js-watch │ ├── test-e2e-watch │ ├── systemd-service-logs │ ├── elixir-release-create │ ├── server-prod-migrate-start │ ├── test-elixir │ ├── containers │ │ ├── backups │ │ │ └── postgres │ │ │ │ ├── backups-list │ │ │ │ ├── backup-create-outside-container │ │ │ │ └── backup-restore-outside-container │ │ ├── README.md │ │ ├── compose--phoenix │ │ ├── compose--postgres │ │ ├── compose--grafana │ │ ├── compose--prometheus │ │ ├── compose--phoenix-postgres │ │ ├── compose--prometheus-grafana │ │ └── compose--phoenix-postgres-traefik │ ├── deploy │ ├── loadtest-wrk │ ├── test-e2e │ ├── pre-commit │ ├── dotenv-generate │ ├── systemd-container-service-teardown │ ├── systemd-container-service-bootstrap │ ├── caddyfile-generate.placeholder │ ├── dotenv-generate--template │ ├── caddyfile-copy-validate-reload │ ├── systemd-native-service-generate │ └── test-elixir-watch └── deployment │ └── caddy │ ├── Caddyfile.test │ ├── Caddyfile.staging │ ├── Caddyfile.staging-test │ ├── Caddyfile.prod │ ├── Caddyfile.dev │ └── Caddyfile.vagrant ├── Dockerfile ├── .tool-versions ├── .envrc ├── rel └── overlays │ └── bin │ ├── migrate.bat │ ├── server.bat │ ├── server │ └── migrate ├── priv ├── repo │ ├── migrations │ │ ├── .formatter.exs │ │ ├── 20230207055302_create_todos.exs │ │ └── 20230207053009_create_users_auth_tables.exs │ └── seeds.exs ├── static │ ├── favicon.ico │ ├── robots.txt │ └── sitemap.xml └── gettext │ ├── default.pot │ └── errors.pot ├── lib ├── todo_list │ ├── mailer.ex │ ├── repo.ex │ ├── repo │ │ └── helpers.ex │ ├── helpers.ex │ ├── sentry.ex │ ├── release.ex │ ├── todos │ │ └── todo.ex │ ├── application.ex │ └── accounts │ │ └── user_notifier.ex ├── todo_list_web │ ├── base │ │ ├── components │ │ │ ├── layouts.ex │ │ │ └── layouts │ │ │ │ └── app.html.heex │ │ ├── controllers │ │ │ ├── base_html.ex │ │ │ ├── base_html │ │ │ │ └── contact_us.html.heex │ │ │ ├── error_json.ex │ │ │ ├── error_html.ex │ │ │ ├── fallback_controller.ex │ │ │ └── base_controller.ex │ │ ├── live │ │ │ ├── home_live.ex │ │ │ └── home_live.html.heex │ │ ├── api │ │ │ └── changeset_json.ex │ │ ├── helpers.ex │ │ ├── router.ex │ │ └── plugs.ex │ ├── todos │ │ ├── controllers │ │ │ ├── todo_html.ex │ │ │ ├── todo_html │ │ │ │ ├── new.html.heex │ │ │ │ ├── edit.html.heex │ │ │ │ ├── show.html.heex │ │ │ │ └── index.html.heex │ │ │ └── todo_controller.ex │ │ ├── api │ │ │ └── todo_json.ex │ │ └── router.ex │ ├── accounts │ │ ├── controllers │ │ │ ├── user_html.ex │ │ │ ├── user_session_html.ex │ │ │ ├── accounts_controller.ex │ │ │ ├── user_session_html │ │ │ │ ├── show.html.heex │ │ │ │ └── update.html.heex │ │ │ └── user_session_controller.ex │ │ ├── api │ │ │ └── accounts_json.ex │ │ ├── live │ │ │ ├── user_delete_live.ex │ │ │ ├── user_logout_live.ex │ │ │ ├── user_login_live.ex │ │ │ ├── user_confirmation_instructions_live.ex │ │ │ ├── user_forgot_password_live.ex │ │ │ ├── user_confirmation_live.ex │ │ │ ├── user_reset_password_live.ex │ │ │ ├── user_registration_live.ex │ │ │ └── user_update_password_live.ex │ │ └── router.ex │ ├── gettext.ex │ ├── api_spec.ex │ └── endpoint.ex ├── presence.ex └── todo_list.ex ├── compose.yaml ├── .formatter.exs ├── .editorconfig ├── git-conventional-commits.yaml ├── config ├── prod.exs ├── test.exs └── config.exs ├── LICENSE ├── .pre-commit-config.yaml ├── .gitignore ├── fly.toml ├── .dockerignore ├── CHANGELOG.md └── mix.exs /test/js: -------------------------------------------------------------------------------- 1 | ../assets/tests/ -------------------------------------------------------------------------------- /assets/.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /support/containers/.env: -------------------------------------------------------------------------------- 1 | ../../.env -------------------------------------------------------------------------------- /assets/.prettierignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | -------------------------------------------------------------------------------- /support/scripts/loadtest-k6: -------------------------------------------------------------------------------- 1 | loadtest_k6.py -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | support/containers/Dockerfile.base -------------------------------------------------------------------------------- /assets/tests/e2e/.auth/.prettierignore: -------------------------------------------------------------------------------- 1 | * 2 | -------------------------------------------------------------------------------- /support/scripts/loadtest: -------------------------------------------------------------------------------- 1 | ../../test/loadtest/ -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.14.5-otp-26 2 | erlang 26.1.2 3 | -------------------------------------------------------------------------------- /support/containers/backups/postgres/.gitignore: -------------------------------------------------------------------------------- 1 | *.dump 2 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | dotenv_if_exists .env 2 | source_env_if_exists gitignored.envrc 3 | -------------------------------------------------------------------------------- /assets/js/base/hooks.ts: -------------------------------------------------------------------------------- 1 | const Hooks = {}; 2 | 3 | export default Hooks; 4 | -------------------------------------------------------------------------------- /assets/tests/e2e/.auth/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | !.prettierignore 4 | -------------------------------------------------------------------------------- /assets/tests/e2e/support/setup/example.ts: -------------------------------------------------------------------------------- 1 | console.log("Example setup file"); 2 | -------------------------------------------------------------------------------- /assets/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@alpinejs/collapse"; 2 | declare module "daisyui"; 3 | -------------------------------------------------------------------------------- /rel/overlays/bin/migrate.bat: -------------------------------------------------------------------------------- 1 | call "%~dp0\todo_list" eval TodoList.Release.migrate 2 | -------------------------------------------------------------------------------- /rel/overlays/bin/server.bat: -------------------------------------------------------------------------------- 1 | set PHX_SERVER=true 2 | call "%~dp0\todo_list" start 3 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(TodoList.Repo, :manual) 3 | -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /support/containers/compose.phoenix-config-traefik-staging.yaml: -------------------------------------------------------------------------------- 1 | compose.phoenix-config-traefik-prod.yaml -------------------------------------------------------------------------------- /assets/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | # /test-results/ 3 | /playwright-report/ 4 | /playwright/.cache/ 5 | -------------------------------------------------------------------------------- /assets/tests/e2e/support/constants.ts: -------------------------------------------------------------------------------- 1 | export const storageState = "tests/e2e/.auth/storageState.json"; 2 | -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arcanemachine/phoenix-todo-list/HEAD/priv/static/favicon.ico -------------------------------------------------------------------------------- /rel/overlays/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd -P -- "$(dirname -- "$0")" 3 | PHX_SERVER=true exec ./todo_list start 4 | -------------------------------------------------------------------------------- /rel/overlays/bin/migrate: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd -P -- "$(dirname -- "$0")" 3 | exec ./todo_list eval TodoList.Release.migrate 4 | -------------------------------------------------------------------------------- /support/containers/networks/compose.phoenix-host.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | phoenix: 4 | network_mode: "host" 5 | -------------------------------------------------------------------------------- /support/containers/networks/compose.postgres-host.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | postgres: 4 | network_mode: "host" 5 | -------------------------------------------------------------------------------- /assets/js/constants.ts: -------------------------------------------------------------------------------- 1 | const constants = { 2 | transitionDurationDefault: 500, 3 | }; 4 | 5 | export default constants; 6 | -------------------------------------------------------------------------------- /support/deployment/caddy/Caddyfile.test: -------------------------------------------------------------------------------- 1 | phoenix-todo-list.test.nicholasmoen.com 2 | 3 | encode gzip 4 | reverse_proxy :4000 5 | -------------------------------------------------------------------------------- /lib/todo_list/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoList.Mailer do 2 | @moduledoc false 3 | use Swoosh.Mailer, otp_app: :todo_list 4 | end 5 | -------------------------------------------------------------------------------- /support/containers/networks/compose.postgres-expose.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | postgres: 4 | ports: 5 | - "5432:5432" 6 | -------------------------------------------------------------------------------- /support/deployment/caddy/Caddyfile.staging: -------------------------------------------------------------------------------- 1 | phoenix-todo-list.staging.nicholasmoen.com 2 | 3 | encode gzip 4 | reverse_proxy :4000 5 | -------------------------------------------------------------------------------- /support/containers/backups/postgres/backup-create-outside-container: -------------------------------------------------------------------------------- 1 | ../../../scripts/containers/backups/postgres/backup-create-outside-container -------------------------------------------------------------------------------- /support/containers/networks/compose.phoenix-expose.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | phoenix: 4 | ports: 5 | - "${PORT:?}:${PORT:?}" 6 | -------------------------------------------------------------------------------- /support/containers/backups/postgres/backup-restore-outside-container: -------------------------------------------------------------------------------- 1 | ../../../scripts/containers/backups/postgres/backup-restore-outside-container -------------------------------------------------------------------------------- /assets/tests/e2e/support/teardown.ts: -------------------------------------------------------------------------------- 1 | async function globalTeardown() { 2 | // throw "Goodbye world!"; 3 | } 4 | 5 | export default globalTeardown; 6 | -------------------------------------------------------------------------------- /lib/todo_list/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoList.Repo do 2 | use Ecto.Repo, 3 | otp_app: :todo_list, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /lib/todo_list_web/base/components/layouts.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.Layouts do 2 | @moduledoc false 3 | 4 | use TodoListWeb, :html 5 | 6 | embed_templates "layouts/*" 7 | end 8 | -------------------------------------------------------------------------------- /lib/todo_list/repo/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoList.Repo.Helpers do 2 | @moduledoc false 3 | 4 | def count(model) do 5 | TodoList.Repo.aggregate(model, :count, :id) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/todo_list_web/base/controllers/base_html.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.BaseHTML do 2 | @moduledoc false 3 | 4 | use TodoListWeb, :html 5 | 6 | embed_templates "base_html/*" 7 | end 8 | -------------------------------------------------------------------------------- /lib/todo_list_web/todos/controllers/todo_html.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.TodoHTML do 2 | @moduledoc false 3 | 4 | use TodoListWeb, :html 5 | 6 | embed_templates "todo_html/*" 7 | end 8 | -------------------------------------------------------------------------------- /lib/todo_list_web/accounts/controllers/user_html.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.UserHTML do 2 | @moduledoc false 3 | 4 | use TodoListWeb, :html 5 | 6 | embed_templates "user_html/*" 7 | end 8 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | phoenix: 4 | image: "docker.io/arcanemachine/phoenix-todo-list" 5 | restart: "always" 6 | env_file: 7 | - ".env" 8 | network_mode: "host" 9 | -------------------------------------------------------------------------------- /lib/presence.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoList.Presence do 2 | @moduledoc """ 3 | The Presence module. 4 | """ 5 | use Phoenix.Presence, 6 | otp_app: :todo_list, 7 | pubsub_server: TodoList.PubSub 8 | end 9 | -------------------------------------------------------------------------------- /lib/todo_list_web/accounts/controllers/user_session_html.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.UserSessionHTML do 2 | @moduledoc false 3 | use TodoListWeb, :html 4 | 5 | embed_templates "user_session_html/*" 6 | end 7 | -------------------------------------------------------------------------------- /priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | Disallow: /users 4 | Disallow: /users/ 5 | Disallow: /todos 6 | Disallow: /todos/ 7 | 8 | Sitemap: https://phoenix-todo-list.nicholasmoen.com/sitemap.xml 9 | -------------------------------------------------------------------------------- /assets/js/hooks.ts: -------------------------------------------------------------------------------- 1 | import BaseHooks from "js/base/hooks"; 2 | import TodosHooks from "js/todos/hooks"; 3 | 4 | const Hooks = { 5 | ...BaseHooks, 6 | ...TodosHooks, 7 | }; 8 | 9 | export default Hooks; 10 | -------------------------------------------------------------------------------- /lib/todo_list_web/base/live/home_live.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.HomeLive do 2 | use TodoListWeb, :live_view 3 | 4 | def mount(_params, _session, socket) do 5 | {:ok, socket |> assign(page_title: "Home")} 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/todo_list/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoList.Helpers do 2 | @moduledoc false 3 | 4 | def generate_random_string(length \\ 32) do 5 | for _ <- 1..length, into: "", do: <> 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /assets/js/helpers.ts: -------------------------------------------------------------------------------- 1 | import baseHelpers from "js/base/helpers"; 2 | // import todosHelpers from "js/todos/helpers"; 3 | 4 | const helpers = { 5 | base: baseHelpers, 6 | // todos: todosHelpers 7 | }; 8 | 9 | export default helpers; 10 | -------------------------------------------------------------------------------- /lib/todo_list_web/base/controllers/base_html/contact_us.html.heex: -------------------------------------------------------------------------------- 1 |
2 | To send us a message, please submit an issue to this project's GitHub repo. 3 |
4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :ecto_sql, :phoenix, :open_api_spex], 3 | subdirectories: ["priv/*/migrations"], 4 | plugins: [Phoenix.LiveView.HTMLFormatter], 5 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] 6 | ] 7 | -------------------------------------------------------------------------------- /assets/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ["tests/unit/**/*.spec.ts"], 6 | environment: "jsdom", 7 | setupFiles: ["tests/unit/support/setup.ts"], 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /support/containers/compose.grafana.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | grafana: 4 | image: "docker.io/grafana/grafana:9.5.2" 5 | restart: "always" 6 | network_mode: "host" 7 | volumes: 8 | - "grafana:/var/lib/grafana" 9 | 10 | volumes: 11 | grafana: 12 | -------------------------------------------------------------------------------- /support/containers/compose.phoenix-config-traefik-dev.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | phoenix: 4 | labels: 5 | - "traefik.enable=true" 6 | - "traefik.http.routers.phoenix-todo-list.rule=Host(`${PHX_HOST:?}`)" 7 | - "traefik.http.routers.phoenix-todo-list.entrypoints=web" 8 | -------------------------------------------------------------------------------- /assets/tests/unit/constants.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import constants from "js/constants"; 4 | 5 | describe("constants", () => { 6 | it("has expected values", () => { 7 | expect(constants.transitionDurationDefault).toBe(500); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /assets/tests/e2e/support/helpers.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from "crypto"; 2 | 3 | export function emailGenerateRandom() { 4 | return `${randomUUID()}@example.com`; 5 | } 6 | 7 | // export function emailGenerateForTestUser(id: number) { 8 | // return `test_user_${id}@example.com`; 9 | // } 10 | -------------------------------------------------------------------------------- /support/containers/compose.phoenix-postgres.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | phoenix: 4 | depends_on: 5 | - "postgres" 6 | networks: 7 | - "postgres" 8 | postgres: 9 | networks: 10 | - "postgres" 11 | ports: 12 | - "5432" 13 | 14 | networks: 15 | postgres: 16 | -------------------------------------------------------------------------------- /support/deployment/caddy/Caddyfile.staging-test: -------------------------------------------------------------------------------- 1 | # use Let's Encrypt test certificates to avoid cert renewal rate limits 2 | { 3 | acme_ca https://acme-staging-v02.api.letsencrypt.org/directory 4 | } 5 | 6 | phoenix-todo-list.staging.nicholasmoen.com 7 | 8 | encode gzip 9 | reverse_proxy :4000 10 | -------------------------------------------------------------------------------- /lib/todo_list.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoList do 2 | @moduledoc """ 3 | This module keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /support/deployment/caddy/Caddyfile.prod: -------------------------------------------------------------------------------- 1 | phoenix-todo-list.nicholasmoen.com { 2 | encode gzip 3 | reverse_proxy :4000 4 | 5 | basicauth /metrics { 6 | # use `mkpasswd --method=bcrypt` to generate a secure password 7 | admin $2b$05$CUm51ELVTYyryw1jJMc74ORRofyu9ilXmGE8Mvl5KAbk0Y.uUp8XO 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/todo_list_web/controllers/base_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.BaseControllerTest do 2 | @moduledoc false 3 | 4 | use TodoListWeb.ConnCase 5 | 6 | test "GET /", %{conn: conn} do 7 | conn = get(conn, ~p"/") 8 | assert html_response(conn, 200) =~ "Todo List" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/loadtest/k6/index.js: -------------------------------------------------------------------------------- 1 | import { check } from "k6"; 2 | import http from "k6/http"; 3 | 4 | export default function () { 5 | const url = __ENV.BASE_URL || `https://${__ENV.PHX_HOST}/`; 6 | 7 | const response = http.get(url); 8 | check(response, { 9 | "status is 200": (res) => res.status === 200, 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /support/scripts/test-js: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 4 | echo "Run this project's Javascript unit tests in watch mode." 5 | exit 6 | fi 7 | 8 | # navigate to the project's npm root directory 9 | cd "$(dirname "$0")/../../assets" || exit 1 10 | 11 | # run the tests 12 | npm run test-unit 13 | -------------------------------------------------------------------------------- /support/scripts/test-js-watch: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 4 | echo "Run this project's Javascript unit tests in watch mode." 5 | exit 6 | fi 7 | 8 | # navigate to the project's npm root directory 9 | cd "$(dirname "$0")/../../assets" || exit 1 10 | 11 | # run the tests 12 | npm run test-unit-watch 13 | -------------------------------------------------------------------------------- /support/containers/compose.phoenix-config-traefik-prod.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | phoenix: 4 | labels: 5 | - "traefik.enable=true" 6 | - "traefik.http.routers.phoenix-todo-list.rule=Host(`${PHX_HOST:?}`)" 7 | - "traefik.http.routers.phoenix-todo-list.entrypoints=websecure" 8 | - "traefik.http.routers.phoenix-todo-list.tls.certresolver=letsencrypt" 9 | -------------------------------------------------------------------------------- /support/containers/compose.prometheus.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | prometheus: 4 | image: "docker.io/prom/prometheus:v2.44.0" 5 | restart: "always" 6 | network_mode: "host" 7 | volumes: 8 | - "prometheus:/etc/prometheus" 9 | - "./etc/${PROMETHEUS_CONFIG_FILE:-prometheus.yml}:/etc/prometheus/prometheus.yml" 10 | 11 | volumes: 12 | prometheus: 13 | -------------------------------------------------------------------------------- /support/containers/etc/dev/traefik.yml: -------------------------------------------------------------------------------- 1 | --- 2 | global: 3 | sendAnonymousUsage: false 4 | checkNewVersion: false 5 | 6 | log: 7 | level: "DEBUG" 8 | 9 | api: 10 | dashboard: true 11 | 12 | providers: 13 | docker: 14 | exposedByDefault: false 15 | network: "traefik-global-proxy" 16 | file: 17 | directory: "/etc/traefik" 18 | 19 | entryPoints: 20 | web: 21 | address: ":80" 22 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # TodoList.Repo.insert!(%TodoList.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://EditorConfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.css] 10 | indent_style = space 11 | indent_size = 2 12 | tab_width = 2 13 | 14 | [*.html] 15 | indent_style = space 16 | indent_size = 2 17 | tab_width = 2 18 | 19 | [*.{js,ts}] 20 | indent_style = space 21 | indent_size = 2 22 | tab_width = 2 23 | -------------------------------------------------------------------------------- /test/loadtest/k6/todo-index.js: -------------------------------------------------------------------------------- 1 | import http from "k6/http"; 2 | 3 | import login from "./login.js"; 4 | 5 | export default function () { 6 | const url = `${__ENV.BASE_URL}/todos`; 7 | 8 | // setup 9 | login(); 10 | 11 | // make request 12 | const response = http.get(url); 13 | 14 | // response returns expected result 15 | check(response, { 16 | "status is 200": (res) => res.status === 200, 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /assets/tests/e2e/base/index/page.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "@playwright/test"; 2 | 3 | import { urls } from "tests/support/constants"; 4 | import { BasePage } from "tests/e2e/base/page"; 5 | 6 | export class BaseIndexPage extends BasePage { 7 | readonly page: Page; 8 | readonly url: URL; 9 | 10 | constructor(page: Page) { 11 | super(page); 12 | this.page = page; 13 | 14 | this.url = urls.base.index; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /support/scripts/test-e2e-watch: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 4 | echo "Run Playwright end-to-end tests in watch mode." 5 | exit 6 | fi 7 | 8 | # navigate to project root directory 9 | cd "$(dirname "$0")/../.." || exit 1 10 | 11 | # watch the test files and re-run when any of the files are modified 12 | until ag -g "assets/tests/.*\.(js|ts)$" | entr -d ./support/scripts/test-e2e "$@"; do sleep 1; done 13 | -------------------------------------------------------------------------------- /lib/todo_list_web/base/live/home_live.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 14 |
15 | -------------------------------------------------------------------------------- /test/todo_list_web/controllers/error_json_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.ErrorJSONTest do 2 | use TodoListWeb.ConnCase, async: true 3 | 4 | test "renders 404" do 5 | assert TodoListWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} 6 | end 7 | 8 | test "renders 500" do 9 | assert TodoListWeb.ErrorJSON.render("500.json", %{}) == 10 | %{errors: %{detail: "Internal Server Error"}} 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /support/scripts/systemd-service-logs: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | service_name="phoenix-todo-list" 4 | 5 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 6 | echo "Show the logs for this project's 'systemd' service." 7 | exit 8 | fi 9 | 10 | # -f - [f]ollow the logs 11 | # -e - jump to the [e]nd of the pager 12 | # -x - add e[x]planatory lines 13 | # -u - monitor a specific [u]nit file (must be positioned last) 14 | 15 | journalctl --user -fexu $service_name 16 | -------------------------------------------------------------------------------- /support/containers/compose.postgres.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | postgres: 4 | image: "docker.io/postgres:16" 5 | restart: "always" 6 | environment: 7 | POSTGRES_DB: "${POSTGRES_DB:?}" 8 | POSTGRES_USER: "${POSTGRES_USER:?}" 9 | POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?}" 10 | volumes: 11 | - "postgres:/var/lib/postgresql/data" 12 | - "./backups/postgres:/var/lib/postgresql/backups" 13 | 14 | volumes: 15 | postgres: 16 | -------------------------------------------------------------------------------- /assets/js/todos/hooks.ts: -------------------------------------------------------------------------------- 1 | import Alpine from "alpinejs"; 2 | 3 | import { AlpineStore } from "../alpine"; 4 | 5 | const Hooks = { 6 | TodosLive: { 7 | // data 8 | get component() { 9 | const alpineStoreComponents: AlpineStore = Alpine.store("components"); 10 | return alpineStoreComponents.todosLive; 11 | }, 12 | 13 | // lifecycle 14 | mounted() { 15 | this.component.hook = this; 16 | }, 17 | }, 18 | }; 19 | 20 | export default Hooks; 21 | -------------------------------------------------------------------------------- /support/containers/etc/prometheus.yml: -------------------------------------------------------------------------------- 1 | --- 2 | global: 3 | scrape_interval: 5s 4 | evaluation_interval: 5s 5 | 6 | external_labels: 7 | monitor: "prometheus" 8 | 9 | # targets to scrape 10 | scrape_configs: 11 | - job_name: "prometheus" 12 | static_configs: 13 | - targets: ["localhost:9090"] 14 | 15 | - job_name: "phoenix-todo-list" 16 | static_configs: 17 | - targets: ["localhost:4001"] 18 | # - targets: ["phoenix-todo-list.your-domain.com"] 19 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230207055302_create_todos.exs: -------------------------------------------------------------------------------- 1 | defmodule TodoList.Repo.Migrations.CreateTodos do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | create table(:todos) do 8 | add :content, :string 9 | add :is_completed, :boolean, default: false, null: false 10 | add :user_id, references(:users, on_delete: :delete_all) 11 | 12 | timestamps() 13 | end 14 | 15 | create index(:todos, [:user_id]) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /assets/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2016", 4 | "module": "commonjs", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "skipLibCheck": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "baseUrl": "./", 10 | "paths": { 11 | "assets/*": ["./*"], 12 | "js/*": ["./js/*"], 13 | "tests/*": ["./tests/*"] 14 | } 15 | }, 16 | "$schema": "https://json.schemastore.org/tsconfig", 17 | "display": "Recommended" 18 | } 19 | -------------------------------------------------------------------------------- /assets/tests/unit/support/setup.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | // polyfill: window.matchMedia 4 | Object.defineProperty(window, "matchMedia", { 5 | writable: true, 6 | value: vi.fn().mockImplementation((query) => ({ 7 | matches: false, 8 | media: query, 9 | onchange: null, 10 | addListener: vi.fn(), // deprecated 11 | removeListener: vi.fn(), // deprecated 12 | addEventListener: vi.fn(), 13 | removeEventListener: vi.fn(), 14 | dispatchEvent: vi.fn(), 15 | })), 16 | }); 17 | -------------------------------------------------------------------------------- /test/todo_list_web/controllers/error_html_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.ErrorHTMLTest do 2 | use TodoListWeb.ConnCase, async: true 3 | 4 | # Bring render_to_string/4 for testing custom views 5 | import Phoenix.Template 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(TodoListWeb.ErrorHTML, "404", "html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(TodoListWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /assets/tests/e2e/base/index/test.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | import { BaseIndexPage } from "./page"; 4 | 5 | test.describe("Base index page", () => { 6 | let testPage: BaseIndexPage; 7 | 8 | test.beforeEach(async ({ page }) => { 9 | // navigate to test page 10 | testPage = new BaseIndexPage(page); 11 | await testPage.goto(); 12 | }); 13 | 14 | test("contains expected title", async () => { 15 | await expect(testPage.pageTitle).toHaveText("Home"); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /assets/js/alpine.ts: -------------------------------------------------------------------------------- 1 | import { 2 | data as baseData, 3 | directives as baseDirectives, 4 | stores as baseStores, 5 | } from "js/base/alpine"; 6 | import { data as todosData } from "js/todos/alpine"; 7 | 8 | // export generic alpine types 9 | export type AlpineComponent = any; 10 | export type AlpineInstance = any; 11 | export type AlpineStore = any; 12 | 13 | export const data: Array = [...baseData, ...todosData]; 14 | export const directives: Array = [...baseDirectives]; 15 | export const stores: Array> = [...baseStores]; 16 | -------------------------------------------------------------------------------- /lib/todo_list_web/accounts/api/accounts_json.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.Api.AccountsJSON do 2 | @moduledoc false 3 | alias TodoList.Accounts.User 4 | 5 | # @doc """ 6 | # Renders a list of users. 7 | # """ 8 | # def index(%{users: users}) do 9 | # %{data: for(user <- users, do: data(user))} 10 | # end 11 | 12 | @doc """ 13 | Renders a single user. 14 | """ 15 | def show(%{user: user}) do 16 | %{data: data(user)} 17 | end 18 | 19 | defp data(%User{} = user) do 20 | %{ 21 | id: user.id, 22 | email: user.email 23 | } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/todo_list_web/todos/controllers/todo_html/new.html.heex: -------------------------------------------------------------------------------- 1 |
2 | <.form_error_alert /> 3 |
4 | 5 | <.simple_form :let={f} for={@changeset} action={~p"/todos"}> 6 | <.input field={{f, :content}} type="text" label="Content" required /> 7 | <.input field={{f, :is_completed}} type="checkbox" label="Is completed?" /> 8 | <:actions> 9 | <.form_button_cancel /> 10 | <.form_button_submit /> 11 | 12 | 13 | 14 | <.action_links items={[ 15 | %{content: "Return to Todos", navigate: ~p"/todos"} 16 | ]} /> 17 | -------------------------------------------------------------------------------- /lib/todo_list_web/todos/api/todo_json.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.Api.TodoJSON do 2 | @moduledoc false 3 | alias TodoList.Todos.Todo 4 | 5 | @doc """ 6 | Renders a list of todos. 7 | """ 8 | def index(%{todos: todos}) do 9 | %{data: for(todo <- todos, do: data(todo))} 10 | end 11 | 12 | @doc """ 13 | Renders a single todo. 14 | """ 15 | def show(%{todo: todo}) do 16 | %{data: data(todo)} 17 | end 18 | 19 | defp data(%Todo{} = todo) do 20 | %{ 21 | id: todo.id, 22 | content: todo.content, 23 | is_completed: todo.is_completed 24 | } 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /support/containers/compose.phoenix.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | phoenix: 4 | image: "docker.io/arcanemachine/phoenix-todo-list:${IMAGE_TAG:-latest}" 5 | restart: "always" 6 | environment: 7 | # phoenix 8 | PHX_HOST: "${PHX_HOST:?}" 9 | PORT: "${PORT:?}" 10 | SECRET_KEY_BASE: "${SECRET_KEY_BASE:?}" 11 | 12 | # database 13 | DATABASE_URL: "${DATABASE_URL:?}" 14 | 15 | # email 16 | AWS_REGION: "${AWS_REGION:?}" 17 | AWS_ACCESS_KEY: "${AWS_ACCESS_KEY:?}" 18 | AWS_SECRET: "${AWS_SECRET}" 19 | 20 | # sentry 21 | SENTRY_DSN: "${SENTRY_DSN}" 22 | -------------------------------------------------------------------------------- /support/containers/etc/dev/middleware.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http: 3 | middlewares: 4 | redirect-non-www-to-www: 5 | redirectregex: 6 | regex: "^https?://(?:www\\.)?(.+)" # also redirects http -> https 7 | replacement: "https://www.${1}" 8 | permanent: true 9 | redirect-www-to-non-www: 10 | redirectregex: 11 | regex: "^https?://www\\.(.+)" # also redirects http -> https 12 | replacement: "https://${1}" 13 | permanent: true 14 | short-analytics-url: 15 | replacepathregex: 16 | regex: "^(.*)/pl.js$$" 17 | replacement: "${1}/plausible.js" 18 | -------------------------------------------------------------------------------- /support/containers/etc/prod/middleware.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http: 3 | middlewares: 4 | redirect-non-www-to-www: 5 | redirectregex: 6 | regex: "^https?://(?:www\\.)?(.+)" # also redirects http -> https 7 | replacement: "https://www.${1}" 8 | permanent: true 9 | redirect-www-to-non-www: 10 | redirectregex: 11 | regex: "^https?://www\\.(.+)" # also redirects http -> https 12 | replacement: "https://${1}" 13 | permanent: true 14 | short-analytics-url: 15 | replacepathregex: 16 | regex: "^(.*)/pl.js$$" 17 | replacement: "${1}/plausible.js" 18 | -------------------------------------------------------------------------------- /support/containers/etc/staging/middleware.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http: 3 | middlewares: 4 | redirect-non-www-to-www: 5 | redirectregex: 6 | regex: "^https?://(?:www\\.)?(.+)" # also redirects http -> https 7 | replacement: "https://www.${1}" 8 | permanent: true 9 | redirect-www-to-non-www: 10 | redirectregex: 11 | regex: "^https?://www\\.(.+)" # also redirects http -> https 12 | replacement: "https://${1}" 13 | permanent: true 14 | short-analytics-url: 15 | replacepathregex: 16 | regex: "^(.*)/pl.js$$" 17 | replacement: "${1}/plausible.js" 18 | -------------------------------------------------------------------------------- /lib/todo_list_web/base/controllers/error_json.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.ErrorJSON do 2 | @moduledoc false 3 | 4 | # If you want to customize a particular status code, 5 | # you may add your own clauses, such as: 6 | # 7 | # def render("500.json", _assigns) do 8 | # %{errors: %{detail: "Internal Server Error"}} 9 | # end 10 | 11 | # By default, Phoenix returns the status message from 12 | # the template name. For example, "404.json" becomes 13 | # "Not Found". 14 | def render(template, _assigns) do 15 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /support/scripts/elixir-release-create: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 4 | echo "Create a new Phoenix release." 5 | exit 6 | fi 7 | 8 | # navigate to project root directory 9 | cd "$(dirname "$0")/../../" || exit 1 10 | 11 | # fetch and compile dependencies 12 | echo "Fetching and compiling dependencies..." 13 | mix deps.get --only prod 14 | MIX_ENV=prod mix compile 15 | 16 | # compile assets 17 | echo "Compiling assets..." 18 | MIX_ENV=prod mix assets.deploy 19 | 20 | # create release 21 | echo "Creating release..." 22 | mix phx.gen.release "$@" 23 | MIX_ENV=prod mix release 24 | 25 | echo "done" 26 | -------------------------------------------------------------------------------- /lib/todo_list/sentry.ex: -------------------------------------------------------------------------------- 1 | defmodule Sentry.FinchClient do 2 | @moduledoc "Configure Sentry to use Finch as its HTTP client." 3 | @behaviour Sentry.HTTPClient 4 | 5 | def child_spec() do 6 | Supervisor.child_spec({Finch, name: __MODULE__}, id: __MODULE__) 7 | end 8 | 9 | def post(url, headers, body) do 10 | case :post 11 | |> Finch.build(url, headers, body) 12 | |> Finch.request(__MODULE__) do 13 | {:ok, %Finch.Response{status: status, headers: headers, body: body}} -> 14 | {:ok, status, headers, body} 15 | 16 | {:error, error} -> 17 | {:error, error} 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/todo_list_web/todos/controllers/todo_html/edit.html.heex: -------------------------------------------------------------------------------- 1 |
2 | <.form_error_alert /> 3 |
4 | 5 | <.simple_form :let={f} for={@changeset} method="put" action={~p"/todos/#{@todo}"}> 6 | <.input field={{f, :content}} type="text" label="Content" required /> 7 | <.input field={{f, :is_completed}} type="checkbox" label="Is completed" /> 8 | <:actions> 9 | <.form_button_cancel /> 10 | <.form_button_submit /> 11 | 12 | 13 | 14 | <.action_links 15 | class="mt-16" 16 | items={[ 17 | %{content: "Return to Todos", navigate: ~p"/todos"} 18 | ]} 19 | /> 20 | -------------------------------------------------------------------------------- /lib/todo_list_web/accounts/controllers/accounts_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.AccountsController do 2 | use TodoListWeb, :controller 3 | 4 | alias TodoList.Accounts 5 | alias TodoListWeb.UserAuth 6 | 7 | def root(conn, _params) do 8 | conn |> redirect(to: ~p"/users/profile") 9 | end 10 | 11 | def delete(conn, _params) do 12 | # get user 13 | user = conn.assigns[:current_user] 14 | 15 | # delete the user 16 | Accounts.delete_user(user) 17 | 18 | # queue success message and log the user out 19 | conn 20 | |> put_flash(:info, "Account deleted successfully") 21 | |> UserAuth.logout_user() 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /support/containers/compose.traefik.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | traefik: 4 | image: "docker.io/traefik:v2.10" 5 | environment: 6 | TRAEFIK_DASHBOARD_FQDN: "${TRAEFIK_DASHBOARD_FQDN:?}" 7 | volumes: 8 | - "${DOCKER_HOST:-/var/run/docker.sock}:/var/run/docker.sock:ro" 9 | networks: 10 | - "traefik-global-proxy" 11 | ports: 12 | - "80:80" 13 | labels: 14 | - "traefik.http.routers.traefik.rule=Host(`${TRAEFIK_DASHBOARD_FQDN}`)" 15 | 16 | # enable the dashboard 17 | - "traefik.enable=true" 18 | - "traefik.http.routers.traefik.service=api@internal" 19 | 20 | networks: 21 | traefik-global-proxy: 22 | -------------------------------------------------------------------------------- /support/scripts/server-prod-migrate-start: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 4 | echo "Run migrations and start the prod server." 5 | exit 6 | fi 7 | 8 | project_name="todo_list" 9 | current_script_directory="$(dirname "$0")" 10 | project_root_directory="$(cd "${current_script_directory}/../.." && pwd)" 11 | working_directory="${project_root_directory}/_build/prod/rel/${project_name}/bin" 12 | 13 | # navigate to working directory 14 | cd "$working_directory" || exit 1 15 | 16 | # run migrations 17 | echo "Running migrations..." 18 | ./migrate 19 | 20 | # start the server 21 | echo "Starting the prod server..." 22 | ./server 23 | -------------------------------------------------------------------------------- /lib/todo_list_web/todos/controllers/todo_html/show.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
    3 |
  • 4 | Content: 5 | <%= @todo.content %> 6 |
  • 7 |
  • 8 | Is Completed? 9 | <%= (@todo.is_completed && "Yes") || "No" %> 10 |
  • 11 |
12 |
13 | 14 | <.action_links items={[ 15 | %{content: "Update this Todo", href: ~p"/todos/#{@todo}/edit"}, 16 | %{ 17 | content: "Delete this Todo", 18 | href: ~p"/todos/#{@todo}", 19 | method: "delete", 20 | confirm: "Are you sure?" 21 | }, 22 | %{content: "View all Todos", href: ~p"/todos", class: "mt-6"} 23 | ]} /> 24 | -------------------------------------------------------------------------------- /support/containers/etc/prod/traefik.yml: -------------------------------------------------------------------------------- 1 | --- 2 | global: 3 | sendAnonymousUsage: false 4 | checkNewVersion: false 5 | 6 | api: 7 | dashboard: true 8 | 9 | providers: 10 | docker: 11 | exposedByDefault: false 12 | network: "traefik-global-proxy" 13 | file: 14 | directory: "/etc/traefik" 15 | 16 | entryPoints: 17 | web: 18 | address: ":80" 19 | http: 20 | redirections: 21 | entryPoint: 22 | to: "websecure" 23 | scheme: "https" 24 | websecure: 25 | address: ":443" 26 | 27 | certificatesResolvers: 28 | letsencrypt: 29 | acme: 30 | email: "letsencrypt@example.com" 31 | storage: "/letsencrypt/acme.json" 32 | tlsChallenge: {} 33 | -------------------------------------------------------------------------------- /lib/todo_list_web/base/controllers/error_html.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.ErrorHTML do 2 | use TodoListWeb, :html 3 | 4 | # If you want to customize your error pages, 5 | # uncomment the embed_templates/1 call below 6 | # and add pages to the error directory: 7 | # 8 | # * lib/todo_list_web/controllers/error_html/404.html.heex 9 | # * lib/todo_list_web/controllers/error_html/500.html.heex 10 | # 11 | # embed_templates "error_html/*" 12 | 13 | # The default is to render a plain text page based on 14 | # the template name. For example, "404.html" becomes 15 | # "Not Found". 16 | def render(template, _assigns) do 17 | Phoenix.Controller.status_message_from_template(template) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/todo_list/release.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoList.Release do 2 | @moduledoc """ 3 | Used for executing DB release tasks when run in production without Mix 4 | installed. 5 | """ 6 | @app :todo_list 7 | 8 | def migrate do 9 | load_app() 10 | 11 | for repo <- repos() do 12 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) 13 | end 14 | end 15 | 16 | def rollback(repo, version) do 17 | load_app() 18 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) 19 | end 20 | 21 | defp repos do 22 | Application.fetch_env!(@app, :ecto_repos) 23 | end 24 | 25 | defp load_app do 26 | Application.load(@app) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /support/deployment/caddy/Caddyfile.dev: -------------------------------------------------------------------------------- 1 | phoenix-todo-list.localhost { 2 | encode gzip 3 | reverse_proxy :4000 4 | 5 | basicauth /metrics { 6 | # use `mkpasswd --method=bcrypt` to generate a secure password 7 | # default password for dev config is 'admin' 8 | admin $2b$05$Z0uqobvCRq0E11ZUmNwON.BJQKU3xXwGndjblofiQ.6DC9JUYu7Be 9 | } 10 | } 11 | 12 | dev.phoenix-todo-list.localhost { 13 | encode gzip 14 | reverse_proxy :4001 15 | 16 | # # use `mkpasswd --method=bcrypt` to generate a secure password 17 | # basicauth /metrics { 18 | # # use `mkpasswd --method=bcrypt` to generate a secure password 19 | # # default password for dev config is 'admin' 20 | # admin $2b$05$Z0uqobvCRq0E11ZUmNwON.BJQKU3xXwGndjblofiQ.6DC9JUYu7Be 21 | # } 22 | } 23 | -------------------------------------------------------------------------------- /support/containers/etc/staging/traefik.yml: -------------------------------------------------------------------------------- 1 | --- 2 | global: 3 | sendAnonymousUsage: false 4 | checkNewVersion: false 5 | 6 | api: 7 | dashboard: true 8 | 9 | providers: 10 | docker: 11 | exposedByDefault: false 12 | network: "traefik-global-proxy" 13 | file: 14 | directory: "/etc/traefik" 15 | 16 | entryPoints: 17 | web: 18 | address: ":80" 19 | http: 20 | redirections: 21 | entryPoint: 22 | to: "websecure" 23 | scheme: "https" 24 | websecure: 25 | address: ":443" 26 | 27 | certificatesResolvers: 28 | letsencrypt: 29 | acme: 30 | caServer: https://acme-staging-v02.api.letsencrypt.org/directory 31 | email: "letsencrypt@example.com" 32 | storage: "/letsencrypt/acme.json" 33 | tlsChallenge: {} 34 | -------------------------------------------------------------------------------- /priv/gettext/default.pot: -------------------------------------------------------------------------------- 1 | ## This file is a PO Template file. 2 | ## 3 | ## "msgid"s here are often extracted from source code. 4 | ## Add new messages manually only if they're dynamic 5 | ## messages that can't be statically extracted. 6 | ## 7 | ## Run "mix gettext.extract" to bring this file up to 8 | ## date. Leave "msgstr"s empty as changing them here has no 9 | ## effect: edit them in PO (.po) files instead. 10 | # 11 | msgid "" 12 | msgstr "" 13 | 14 | #: lib/todo_list_web/base/components/core_components.ex:866 15 | #, elixir-autogen, elixir-format 16 | msgid "Actions" 17 | msgstr "" 18 | 19 | #: lib/todo_list_web/base/components/core_components.ex:195 20 | #: lib/todo_list_web/base/components/core_components.ex:402 21 | #, elixir-autogen, elixir-format 22 | msgid "close" 23 | msgstr "" 24 | -------------------------------------------------------------------------------- /assets/js/init/page.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const darkModeEnabled = (() => { 3 | const darkModeSavedPreferenceExists = 4 | localStorage.getItem("darkModeEnabled") !== null; 5 | 6 | if (darkModeSavedPreferenceExists) { 7 | // use saved preference 8 | const savedDarkModePreference = JSON.parse( 9 | localStorage.getItem("darkModeEnabled") || "0" 10 | ); 11 | return Boolean(savedDarkModePreference); 12 | } else { 13 | // use browser preference 14 | const browserDarkModePreference = window.matchMedia( 15 | "(prefers-color-scheme: dark)" 16 | ); 17 | 18 | return browserDarkModePreference.matches; 19 | } 20 | })(); 21 | 22 | document.querySelector("html").dataset.theme = darkModeEnabled 23 | ? "dark" 24 | : "default"; 25 | })(); 26 | -------------------------------------------------------------------------------- /support/scripts/test-elixir: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 4 | echo "Run this project's Elixir tests using 'mix test'." 5 | exit 6 | fi 7 | 8 | # navigate to project root directory 9 | cd "$(dirname "$0")/../.." || exit 1 10 | 11 | # reset the database (to prevent issues with non-empty test database, e.g. from 12 | # aborted E2E tests) 13 | echo "Resetting the test database..." 14 | MIX_ENV="test" mix ecto.reset >/dev/null 15 | 16 | # show success message after resetting the test database 17 | # shellcheck disable=SC2181 # ignore unnecessary warning 18 | if [ "$?" = 0 ]; then 19 | echo "Test database reset successfully." 20 | else 21 | echo "\033[91mAn error occurred while attempting to reset the test database.\033[39m" 22 | fi 23 | 24 | # run the tests 25 | mix test 26 | -------------------------------------------------------------------------------- /lib/todo_list_web/base/controllers/fallback_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.FallbackController do 2 | @moduledoc """ 3 | Translates controller action results into valid `Plug.Conn` responses. 4 | 5 | See `Phoenix.Controller.action_fallback/1` for more details. 6 | """ 7 | use TodoListWeb, :controller 8 | 9 | # This clause is an example of how to handle resources that cannot be found. 10 | def call(conn, {:error, :not_found}) do 11 | conn 12 | |> put_status(:not_found) 13 | |> put_view(html: TodoListWeb.ErrorHTML, json: TodoListWeb.ErrorJSON) 14 | |> render(:"404") 15 | end 16 | 17 | def call(conn, {:error, _}) do 18 | conn 19 | |> put_status(:bad_request) 20 | |> put_view(html: TodoListWeb.ErrorHTML, json: TodoListWeb.ErrorJSON) 21 | |> render(:"400") 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /support/containers/compose.traefik-config-dev.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | traefik: 4 | volumes: 5 | - "./etc/dev:/etc/traefik" 6 | labels: 7 | - "traefik.http.routers.traefik.entrypoints=web" 8 | 9 | # # require authentication to access the dashboard (uncomment the next lines to enable) 10 | # - "traefik.http.routers.traefik.middlewares=auth" 11 | # 12 | # # use `mkpasswd --method=bcrypt` to convert your password to a bcrypt 13 | # # hash before pasting it here. (make sure to double up any dollar sign 14 | # # symbols ($ -> $$) since the dollar sign symbol is used as an escape 15 | # # character in YAML) 16 | # - "traefik.http.middlewares.auth.basicauth.users=${TRAEFIK_DASHBOARD_CREDENTIALS:-admin:$2b$05$ziXkIFQh5zJHZYsmX1LGluwyTYx4UVT2hz/CkEsjbuxG8kdXTfuUm}" 17 | -------------------------------------------------------------------------------- /support/containers/networks/compose.phoenix-traefik.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | phoenix: 4 | environment: 5 | DATABASE_URL: "${DATABASE_URL:?}" 6 | networks: 7 | - "traefik-global-proxy" 8 | # - "postgres" # uncomment this line if you are running a postgres container outside of this project's compose service 9 | ports: 10 | - "${PORT:?}" 11 | labels: 12 | # enable gzip compression 13 | - "traefik.http.middlewares.phoenix-todo-list--compress.compress=true" # define middleware 14 | - "traefik.http.routers.phoenix-todo-list.middlewares=phoenix-todo-list--compress" # use middleware 15 | 16 | networks: 17 | traefik-global-proxy: 18 | external: true 19 | # postgres: # uncomment this line if you are running a postgres container outside of this project's compose service 20 | -------------------------------------------------------------------------------- /support/containers/backups/postgres/backup-restore-inside-container: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | show_help() { 4 | echo "This script restores a pg_dump backup. It must be run inside the container. 5 | 6 | The first positional argument must be the filename of the backup you want to restore: 7 | - The file must be located in the same directory as this script. 8 | - DO NOT pass in the full path to the file. Just the filename. 9 | - e.g. 'phoenix-todo-list--pg-dump-2022-11-01-08-57-30.dump'" 10 | } 11 | 12 | if [ "$1" = "--help" ]; then 13 | show_help 14 | exit 15 | elif [ "$1" = "" ]; then 16 | show_help 17 | exit 2 18 | fi 19 | 20 | # change to backup directory 21 | cd /var/lib/postgresql/backups/ || exit 1 22 | 23 | # restore backup 24 | pg_restore -h localhost -p 5432 -U "$POSTGRES_USER" -d "$POSTGRES_DB" "$1" --clean 25 | -------------------------------------------------------------------------------- /lib/todo_list/todos/todo.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoList.Todos.Todo do 2 | @moduledoc false 3 | use Ecto.Schema 4 | import Ecto.Changeset 5 | 6 | @derive { 7 | Flop.Schema, 8 | filterable: [:id], 9 | sortable: [:id], 10 | max_limit: 10, 11 | default_limit: 10, 12 | default_order: %{ 13 | order_by: [:id], 14 | order_directions: [:desc] 15 | } 16 | } 17 | 18 | schema "todos" do 19 | field :content, :string 20 | field :is_completed, :boolean, default: false 21 | field :user_id, :id 22 | 23 | timestamps() 24 | end 25 | 26 | @doc false 27 | def changeset(todo, attrs) do 28 | todo 29 | |> cast(attrs, [:content, :is_completed, :user_id]) 30 | |> validate_required([:content, :is_completed, :user_id]) 31 | |> foreign_key_constraint(:user_id) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/todo_list_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import TodoListWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :todo_list 24 | end 25 | -------------------------------------------------------------------------------- /support/containers/backups/postgres/backup-create-inside-container: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | show_help() { 4 | echo "This script creates a pg_dump backup. It must be run inside the container. 5 | 6 | The backup file will be created in the same directory as this script. It will be created with a timestamp in the filename. 7 | - e.g. 'phoenix-todo-list--pg-dump-2022-11-05-06-23-37.dump' 8 | 9 | This script takes no arguments." 10 | } 11 | 12 | if [ "$1" = "--help" ]; then 13 | show_help 14 | exit 15 | fi 16 | 17 | # change to backup directory 18 | cd /var/lib/postgresql/backups/ || exit 1 19 | 20 | # make backup 21 | pg_dump -h localhost -p 5432 -U "$POSTGRES_USER" -F c -b -v -f "phoenix-todo-list--pg-dump-$(date +'%Y-%m-%d-%H-%M-%S').dump" "$POSTGRES_DB" 22 | 23 | # # remove all local backups older than 7 days 24 | # find *.dump -type f -mtime +7 | xargs rm -f 25 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "test-e2e": "playwright test", 4 | "test-unit": "vitest run unit/", 5 | "test-unit-watch": "vitest watch unit/", 6 | "test-unit-coverage": "vitest run --coverage" 7 | }, 8 | "dependencies": { 9 | "@alpinejs/collapse": "^3.12.2", 10 | "@alpinejs/focus": "^3.12.2", 11 | "@types/toastify-js": "^1.11.1", 12 | "alpinejs": "^3.12.2", 13 | "daisyui": "^3.0.1", 14 | "tippy.js": "^6.3.7", 15 | "toastify-js": "^1.12.0" 16 | }, 17 | "devDependencies": { 18 | "@playwright/test": "^1.34.3", 19 | "@tailwindcss/typography": "^0.5.9", 20 | "@types/alpinejs": "^3.7.1", 21 | "@types/alpinejs__focus": "^3.10.0", 22 | "@types/jsdom": "^21.1.1", 23 | "jsdom": "^22.1.0", 24 | "prettier": "2.8.8", 25 | "typescript": "^5.1.3", 26 | "vitest": "^0.31.4" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /git-conventional-commits.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | convention: 3 | commitTypes: 4 | - feat 5 | - fix 6 | - perf 7 | - refactor 8 | - style 9 | - test 10 | - build 11 | - ops 12 | - docs 13 | - chore 14 | - merge 15 | - revert 16 | commitScopes: [] 17 | releaseTagGlobPattern: v[0-9]*.[0-9]*.[0-9]* 18 | changelog: 19 | commitTypes: 20 | - feat 21 | - fix 22 | - perf 23 | - merge 24 | includeInvalidCommits: true 25 | commitIgnoreRegexPattern: "^WIP " 26 | headlines: 27 | feat: Features 28 | fix: Bug Fixes 29 | perf: Performance Improvements 30 | merge: Merges 31 | breakingChange: BREAKING CHANGES 32 | commitUrl: https://github.com/arcanemachine/phoenix-todo-list/commit/%commit% 33 | issueUrl: https://github.com/arcanemachine/phoenix-todo-list/issues/%issue% 34 | issueRegexPattern: "#[0-9]+" 35 | -------------------------------------------------------------------------------- /lib/todo_list_web/accounts/controllers/user_session_html/show.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
    4 |
    5 | Email: 6 |
    7 |
    8 | <%= @current_user.email %> 9 |
    10 |
11 |
12 | 13 |
    14 |
  • 15 | <.link class="w-full btn btn-lg btn-primary" href={~p"/users/profile/update"}> 16 | Manage your profile 17 | 18 |
  • 19 |
  • 20 | <.link class="w-full mt-8 btn btn-lg btn-error" href={~p"/users/logout"}> 21 | Log out 22 | 23 |
  • 24 |
25 |
26 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :todo_list, TodoListWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" 13 | 14 | # Configures Swoosh API Client 15 | config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: TodoList.Finch 16 | 17 | # Do not print debug messages in production 18 | config :logger, level: :info 19 | 20 | # Runtime production configuration, including reading 21 | # of environment variables, is done on config/runtime.exs. 22 | -------------------------------------------------------------------------------- /lib/todo_list_web/accounts/controllers/user_session_html/update.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
    3 |
  • 4 | <.link class="w-full btn btn-lg btn-primary" href={~p"/users/profile/update/email"}> 5 | Update Email 6 | 7 |
  • 8 |
  • 9 | <.link class="w-full mt-4 btn btn-lg btn-primary" href={~p"/users/profile/update/password"}> 10 | Update Password 11 | 12 |
  • 13 |
  • 14 | <.link class="w-full mt-8 btn btn-lg btn-error" href={~p"/users/profile/delete"}> 15 | Delete Account 16 | 17 |
  • 18 |
  • 19 | <.link class="w-full mt-8 btn btn-lg btn-secondary" href={~p"/users/profile"}> 20 | Return to Profile 21 | 22 |
  • 23 |
24 |
25 | -------------------------------------------------------------------------------- /support/deployment/caddy/Caddyfile.vagrant: -------------------------------------------------------------------------------- 1 | phoenix-todo-list.localhost 2 | 3 | # To generate a valid self-signed certificate with 'mkcert': 4 | # - Ensure that mkcert is installed on the host machine (mkcert is not needed in the VM) 5 | # - Ensure that a shared Vagrant directory has been created in your Vagrantfile 6 | # - It should point to '/vagrant' in the VM 7 | # - Navigate to the shared directory in the host. 8 | # - Generate the self-signed certificate: 9 | # - mkcert -key-file key.pem -cert-file cert.pem phoenix-todo-list.localhost 10 | # - Add read permissions to the newly-generated 'key.pem' file: 11 | # - chmod 644 key.pem 12 | # - NOTE: Assigning read permissions poses a potential security issue! 13 | # - To be on the safe side, delete the key when you are done with it! 14 | 15 | 16 | tls /vagrant/cert.pem /vagrant/key.pem 17 | 18 | encode gzip 19 | reverse_proxy :4000 20 | -------------------------------------------------------------------------------- /test/support/fixtures/accounts_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoList.AccountsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `TodoList.Accounts` context. 5 | """ 6 | 7 | def unique_user_email, do: "user#{System.unique_integer()}@example.com" 8 | def valid_user_password, do: "valid_password" 9 | 10 | def valid_user_attributes(attrs \\ %{}) do 11 | Enum.into(attrs, %{ 12 | email: unique_user_email(), 13 | password: valid_user_password() 14 | }) 15 | end 16 | 17 | def user_fixture(attrs \\ %{}) do 18 | {:ok, user} = 19 | attrs 20 | |> valid_user_attributes() 21 | |> TodoList.Accounts.register_user() 22 | 23 | user 24 | end 25 | 26 | def extract_user_token(fun) do 27 | {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") 28 | [_, token | _] = String.split(captured_email.text_body, "[TOKEN]") 29 | token 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230207053009_create_users_auth_tables.exs: -------------------------------------------------------------------------------- 1 | defmodule TodoList.Repo.Migrations.CreateUsersAuthTables do 2 | @moduledoc false 3 | use Ecto.Migration 4 | 5 | def change do 6 | execute "CREATE EXTENSION IF NOT EXISTS citext", "" 7 | 8 | create table(:users) do 9 | add :email, :citext, null: false 10 | add :hashed_password, :string, null: false 11 | add :confirmed_at, :naive_datetime 12 | timestamps() 13 | end 14 | 15 | create unique_index(:users, [:email]) 16 | 17 | create table(:users_tokens) do 18 | add :user_id, references(:users, on_delete: :delete_all), null: false 19 | add :token, :binary, null: false 20 | add :context, :string, null: false 21 | add :sent_to, :string 22 | timestamps(updated_at: false) 23 | end 24 | 25 | create index(:users_tokens, [:user_id]) 26 | create unique_index(:users_tokens, [:context, :token]) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright 2023 Nicholas Moen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | software and associated documentation files (the "Software"), to deal in the Software 7 | without restriction, including without limitation the rights to use, copy, modify, 8 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | -------------------------------------------------------------------------------- /lib/todo_list_web/base/api/changeset_json.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.ChangesetJSON do 2 | @moduledoc false 3 | 4 | @doc """ 5 | Renders changeset errors. 6 | """ 7 | def error(%{changeset: changeset}) do 8 | # When encoded, the changeset returns its errors 9 | # as a JSON object. So we just pass it forward. 10 | %{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)} 11 | end 12 | 13 | defp translate_error({msg, opts}) do 14 | # You can make use of gettext to translate error messages by 15 | # uncommenting and adjusting the following code: 16 | 17 | # if count = opts[:count] do 18 | # Gettext.dngettext(TodoListWeb.Gettext, "errors", msg, msg, count, opts) 19 | # else 20 | # Gettext.dgettext(TodoListWeb.Gettext, "errors", msg, opts) 21 | # end 22 | 23 | Enum.reduce(opts, msg, fn {key, value}, acc -> 24 | String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) 25 | end) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /support/scripts/containers/backups/postgres/backups-list: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | show_help() { 4 | echo "Lists available backups inside the Postgres container directory '/var/lib/postgresql/backups/'. 5 | 6 | This script accepts the following environment variable(s): 7 | - POSTGRES_VOLUME_NAME: The name of the Postgres volume to be restored 8 | - Default: 'phoenix-todo-list_postgres' 9 | 10 | This script accepts the following positional arguments: 11 | --podman - When passed as the last positional argument, use Podman instead of Docker" 12 | } 13 | 14 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 15 | show_help 16 | exit 17 | fi 18 | 19 | if [ "$1" = "--podman" ]; then 20 | container_manager=podman 21 | else 22 | container_manager=docker 23 | fi 24 | 25 | printf "Listing available backups in Postgres container volume directory '/var/lib/postgresql/backups/'...\n\n" 26 | 27 | $container_manager exec -it phoenix-todo-list_postgres sh -c 'cd /var/lib/postgresql/backups/ && ls *.dump' 28 | -------------------------------------------------------------------------------- /support/scripts/deploy: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 4 | echo "This script tests, releases, builds, and pushes a Docker image to Docker Hub. 5 | 6 | NOTE: This does not perform the final step of updating the live server. For that, you will need to e.g. use an Ansible playbook to update the server to the newest release." 7 | exit 8 | fi 9 | 10 | # ensure the user has completed the checklist before continuing 11 | echo " 12 | The following tasks should be performed before creating and pushing a release: 13 | 14 | - Increment the project's version number in 'mix.exs'. 15 | - Describe relevant changes in 'CHANGELOG.md'. 16 | - Commit all changes to the primary repo." 17 | 18 | printf "\nAre you ready to continue? (y/N) " 19 | read -r result 20 | 21 | if [ "$result" != "y" ] && [ "$result" != "Y" ]; then 22 | echo "Aborting..." 23 | exit 1 24 | fi 25 | 26 | # run the justfile shortcuts required to deploy a new image to docker hub 27 | just test release build push 28 | -------------------------------------------------------------------------------- /lib/todo_list_web/api_spec.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.ApiSpec do 2 | @moduledoc "Define the OpenAPI specification generator." 3 | 4 | alias OpenApiSpex.{Components, Info, OpenApi, Paths, SecurityScheme, Server} 5 | alias TodoListWeb.{Router} 6 | @behaviour OpenApi 7 | 8 | @impl OpenApi 9 | def spec() do 10 | %OpenApi{ 11 | info: %Info{ 12 | title: "Phoenix Todo List", 13 | version: "0.1.0" 14 | }, 15 | servers: [%Server{url: "https://phoenix-todo-list.nicholasmoen.com/"}], 16 | paths: Paths.from_router(Router), 17 | components: %Components{ 18 | securitySchemes: %{ 19 | "bearerAuth" => %SecurityScheme{ 20 | type: "http", 21 | scheme: "bearer", 22 | in: "header", 23 | name: "authorization", 24 | description: "Bearer authentication with required prefix 'Bearer'" 25 | } 26 | } 27 | } 28 | } 29 | |> OpenApiSpex.resolve_schema_modules() 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /support/containers/compose.traefik-config-prod.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | traefik: 4 | ports: 5 | - "443:443" 6 | volumes: 7 | - "./etc/prod:/etc/traefik" 8 | - "./volumes/letsencrypt:/letsencrypt" 9 | labels: 10 | - "traefik.http.routers.traefik.entrypoints=websecure" 11 | - "traefik.http.routers.traefik.tls.certresolver=letsencrypt" 12 | 13 | # require authentication to access the dashboard (you can comment out the 14 | # next line to disable authentication (not recommended in production!) 15 | - "traefik.http.routers.traefik.middlewares=auth" 16 | 17 | # use `mkpasswd --method=bcrypt` to convert your password to a bcrypt 18 | # hash before pasting it here. (make sure to double up any dollar sign 19 | # symbols ($ -> $$) since the dollar sign symbol is used as an escape 20 | # character in YAML) 21 | - "traefik.http.middlewares.auth.basicauth.users=${TRAEFIK_DASHBOARD_CREDENTIALS:-admin:$2b$05$ziXkIFQh5zJHZYsmX1LGluwyTYx4UVT2hz/CkEsjbuxG8kdXTfuUm}" 22 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v2.3.0 5 | hooks: 6 | # misc 7 | - id: detect-private-key 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | # yaml 11 | - id: check-yaml 12 | 13 | - repo: https://github.com/qoomon/git-conventional-commits 14 | rev: v2.6.3 15 | hooks: 16 | - id: conventional-commits 17 | 18 | - repo: "local" 19 | hooks: 20 | - id: "mix-format" 21 | name: "Format Elixir files" 22 | description: "Formats Elixir files with 'mix format'" 23 | language: "system" 24 | entry: "mix format" 25 | files: "\\.(?:exs|ex|heex)$" 26 | require_serial: true 27 | - id: "mix-test" 28 | name: "Test Elixir files" 29 | description: "Runs Elixir tests with 'mix test'" 30 | language: "system" 31 | entry: "./support/scripts/test-elixir" 32 | files: "\\.(?:exs|ex|heex)$" 33 | require_serial: true 34 | -------------------------------------------------------------------------------- /support/containers/compose.traefik-config-staging.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | traefik: 4 | ports: 5 | - "443:443" 6 | volumes: 7 | - "./etc/staging:/etc/traefik" 8 | - "./volumes/letsencrypt:/letsencrypt" 9 | labels: 10 | - "traefik.http.routers.traefik.entrypoints=websecure" 11 | - "traefik.http.routers.traefik.tls.certresolver=letsencrypt" 12 | 13 | # require authentication to access the dashboard (you can comment out the 14 | # next line to disable authentication (not recommended in production!) 15 | - "traefik.http.routers.traefik.middlewares=auth" 16 | 17 | # use `mkpasswd --method=bcrypt` to convert your password to a bcrypt 18 | # hash before pasting it here. (make sure to double up any dollar sign 19 | # symbols ($ -> $$) since the dollar sign symbol is used as an escape 20 | # character in YAML) 21 | - "traefik.http.middlewares.auth.basicauth.users=${TRAEFIK_DASHBOARD_CREDENTIALS:-admin:$2b$05$ziXkIFQh5zJHZYsmX1LGluwyTYx4UVT2hz/CkEsjbuxG8kdXTfuUm}" 22 | -------------------------------------------------------------------------------- /support/scripts/loadtest-wrk: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 4 | echo "This script runs a load test using 'wrk'. 'wrk' must be installed for this script to work. 5 | 6 | Run this script with no parameters to run a basic load test: 7 | - e.g. 'loadtest-wrk' 8 | 9 | Pass the WRK_URL environment variable to test a specific URL: 10 | - e.g. 'WRK_URL=https://your-project.localhost/ loadtest-wrk' 11 | 12 | Use positional arguments to run a custom 'wrk' command. The \$URL will automatically be appended to the end of the command: 13 | - e.g. 'loadtest-wrk -t12 -c400 -d30s \$URL'" 14 | exit 15 | fi 16 | 17 | WRK_URL=${WRK_URL:-"https://${PHX_HOST:?}"} 18 | 19 | if [ "$1" = "" ]; then 20 | # if no args passed, run a basic test 21 | echo "\033[96mRunning a basic load test on URL '$WRK_URL'...\033[39m" 22 | wrk -t12 -c400 -d10s "$WRK_URL" 23 | else 24 | # run a custom test 25 | echo "\033[96mRunning a custom load test on URL '$WRK_URL'...\033[39m" 26 | # shellcheck disable=SC2068 27 | wrk $@ "$WRK_URL" 28 | fi 29 | -------------------------------------------------------------------------------- /support/scripts/test-e2e: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 4 | echo "Run a batch of end-to-end tests (powered by Playwright)." 5 | exit 6 | fi 7 | 8 | # navigate to project root directory 9 | cd "$(dirname "$0")/../.." || exit 1 10 | 11 | # get reference to project root directory 12 | project_root_directory=$(pwd) 13 | 14 | # navigate to the project's npm root directory 15 | cd assets || exit 1 16 | 17 | # run the tests 18 | npx playwright test "$@" 19 | 20 | # remember return code from test results 21 | test_return_code="$?" 22 | 23 | # reset the database after E2E tests 24 | cd "$project_root_directory" || exit 1 25 | echo "\033[96mE2E tests complete. Resetting the test database...\033[39m" 26 | result=$(MIX_ENV="test" mix ecto.reset >/dev/null) 27 | 28 | # show success message after resetting the test database 29 | if [ "$result" = 0 ]; then 30 | echo "\033[96m" "Test database reset successfully." "\033[39m" 31 | fi 32 | 33 | # pass the return code to the shell (or any parent scripts) 34 | exit $test_return_code 35 | -------------------------------------------------------------------------------- /test/support/fixtures/todos_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoList.TodosFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `TodoList.Todos` context. 5 | """ 6 | 7 | import TodoList.AccountsFixtures 8 | 9 | # def valid_todo_content, do: "some todo content" 10 | def valid_todo_content, do: TodoList.Helpers.generate_random_string() 11 | 12 | def valid_todo_attributes(attrs \\ %{}) do 13 | Enum.into(attrs, %{ 14 | content: valid_todo_content(), 15 | # generate a random boolean 16 | is_completed: :rand.uniform() > 0.5 17 | }) 18 | end 19 | 20 | @doc """ 21 | Generate a todo. 22 | """ 23 | def todo_fixture(attrs \\ %{}) do 24 | # generate user 25 | user_id = attrs[:user_id] || user_fixture().id 26 | 27 | {:ok, todo} = 28 | attrs 29 | |> Enum.into(%{ 30 | content: valid_todo_content(), 31 | is_completed: true, 32 | user_id: user_id 33 | }) 34 | |> TodoList.Todos.create_todo() 35 | 36 | todo 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # environment 2 | .env 3 | 4 | # elixir 5 | /.elixir_ls/ 6 | 7 | # python 8 | __pycache__/ 9 | 10 | # misc 11 | *gitignored* 12 | 13 | 14 | # MIX DEFAULTS # 15 | # The directory Mix will write compiled artifacts to. 16 | /_build/ 17 | 18 | # If you run "mix test --cover", coverage assets end up here. 19 | /cover/ 20 | 21 | # The directory Mix downloads your dependencies sources to. 22 | /deps/ 23 | 24 | # Where 3rd-party dependencies like ExDoc output generated docs. 25 | /doc/ 26 | 27 | # Ignore .fetch files in case you like to edit your project deps locally. 28 | /.fetch 29 | 30 | # If the VM crashes, it generates a dump, let's ignore it too. 31 | erl_crash.dump 32 | 33 | # Also ignore archive artifacts (built via "mix archive.build"). 34 | *.ez 35 | 36 | # Ignore package tarball (built via "mix hex.build"). 37 | todo_list-*.tar 38 | 39 | # Ignore assets that are produced by build tools. 40 | /priv/static/assets/ 41 | 42 | # Ignore digested assets cache. 43 | /priv/static/cache_manifest.json 44 | 45 | # In case you use Node.js/npm, you want to ignore these. 46 | npm-debug.log 47 | node_modules/ 48 | -------------------------------------------------------------------------------- /support/scripts/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 4 | echo "Run tasks as part of a git pre-commit workflow. 5 | 6 | A flag must be passed that describes the action to be performed. 7 | 8 | Flags: 9 | --help - Show this help text and exit. 10 | --test-e2e - Run end-to-end tests." 11 | exit 12 | fi 13 | 14 | # # add PRE_COMMIT environment variable so we can detect when code is being run 15 | # # as part of a pre-commit workflow 16 | # export PRE_COMMIT=1 17 | 18 | # navigate to project root directory 19 | cd "$(dirname "$0")/../.." || exit 1 20 | 21 | if [ "$1" = "" ]; then 22 | echo "A flag must be specified when running this script. Use the '--help' flag for more info." 23 | exit 1 24 | elif [ "$1" = "--test-e2e" ]; then 25 | echo "Running E2E tests..." 26 | 27 | ./support/scripts/test-e2e --quiet --forbid-only --retries 3 28 | if [ "$?" != 0 ]; then 29 | echo "\033[91m" "One or more E2E tests failed. Aborting the commit..." "\033[39m" 30 | exit 1 31 | else 32 | echo "\033[92m" "E2E tests completed successfully." "\033[39m" 33 | exit 0 34 | fi 35 | fi 36 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for arcanemachine-phoenix-todo on 2023-04-23T19:02:12-06:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = "arcanemachine-phoenix-todo" 7 | kill_signal = "SIGTERM" 8 | kill_timeout = 5 9 | primary_region = "ord" 10 | processes = [] 11 | 12 | [build] 13 | 14 | [deploy] 15 | release_command = "/app/bin/migrate" 16 | 17 | [env] 18 | PHX_HOST = "arcanemachine-phoenix-todo.fly.dev" 19 | PORT = "8080" 20 | 21 | [experimental] 22 | auto_rollback = true 23 | 24 | [[services]] 25 | http_checks = [] 26 | internal_port = 8080 27 | processes = ["app"] 28 | protocol = "tcp" 29 | script_checks = [] 30 | [services.concurrency] 31 | hard_limit = 1000 32 | soft_limit = 1000 33 | type = "connections" 34 | 35 | [[services.ports]] 36 | force_https = true 37 | handlers = ["http"] 38 | port = 80 39 | 40 | [[services.ports]] 41 | handlers = ["tls", "http"] 42 | port = 443 43 | 44 | [[services.tcp_checks]] 45 | grace_period = "1s" 46 | interval = "15s" 47 | restart_limit = 0 48 | timeout = "2s" 49 | -------------------------------------------------------------------------------- /lib/todo_list_web/todos/controllers/todo_html/index.html.heex: -------------------------------------------------------------------------------- 1 | <%= if Enum.empty?(@todos) do %> 2 |

You have not created any todo items.

3 | <% else %> 4 |
    5 | <%= for todo <- @todos do %> 6 |
  • 7 | <.link class="text-xl font-bold" navigate={~p"/todos/#{todo}"}> 8 | <%= todo.content %> 9 | 10 |
      11 |
    • 12 | ID: #<%= todo.id %> 13 |
    • 14 |
    • 15 | Is completed? 16 | <%= (todo.is_completed && "Yes") || "No" %> 17 |
    • 18 |
    19 |
  • 20 | <% end %> 21 |
22 |
23 | 32 | <% end %> 33 | 34 | <.action_links items={[ 35 | %{content: "Create new Todo", href: ~p"/todos/new"} 36 | ]} /> 37 | -------------------------------------------------------------------------------- /lib/todo_list_web/base/components/layouts/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 | <.flash id="flash-info" kind={:info} title="Info" flash={@flash} /> 3 | <.flash id="flash-error" kind={:error} title="Error!" flash={@flash} /> 4 | <.flash 5 | id="disconnected" 6 | kind={:error} 7 | title="Lost connection to server" 8 | close={false} 9 | autoshow={false} 10 | phx-disconnected={show_with_delay("#disconnected")} 11 | phx-connected={hide("#disconnected")} 12 | > 13 | Attempting to reconnect 14 | 15 |
16 | 17 | <%= @inner_content %> 18 | <%= if false and "@static_changed?" do %> 19 | <.modal 20 | id="static-files-updated-modal" 21 | show={true} 22 | on_confirm={JS.dispatch("window-reload", to: "html")} 23 | > 24 |
25 |

26 | This app has been updated. 27 |

28 |

29 | Do you want to reload the page? 30 |

31 |
32 | <:cancel>Cancel 33 | <:confirm>OK 34 | 35 | <% end %> 36 | -------------------------------------------------------------------------------- /lib/todo_list_web/accounts/live/user_delete_live.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.UserDeleteLive do 2 | use TodoListWeb, :live_view 3 | 4 | alias TodoList.Accounts 5 | 6 | # lifecycle 7 | def mount(_params, session, socket) do 8 | current_user = Accounts.get_user_by_session_token(session["user_token"]) 9 | 10 | {:ok, 11 | assign(socket, 12 | page_title: "Delete Account", 13 | current_user: current_user 14 | )} 15 | end 16 | 17 | def render(assigns) do 18 | ~H""" 19 | <.header class="my-8 text-center"> 20 | Are you sure you want to delete your account? 21 | 22 | 23 | <.simple_form for={%{}} confirmation_required={true}> 24 | <:actions> 25 | <.form_button_cancel /> 26 | <.link 27 | href={~p"/users/profile/delete"} 28 | method="delete" 29 | class="btn btn-error form-button" 30 | phx-disable-with 31 | > 32 | Yes 33 | 34 | 35 | 36 | 37 | <.action_links items={[ 38 | %{content: "Return to your profile", href: ~p"/users/profile", class: "list-back"} 39 | ]} /> 40 | """ 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/todo_list_web/todos/router.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.Todos.Router do 2 | # BROWSER # 3 | def todos_login_required do 4 | quote do 5 | resources("/todos", TodoController, only: [:index, :new, :create]) 6 | end 7 | end 8 | 9 | def todos_login_required_live_session do 10 | quote do 11 | live "/todos/live", TodosLive 12 | end 13 | end 14 | 15 | def todos_require_todo_permissions do 16 | quote do 17 | resources("/todos", TodoController, only: [:show, :edit, :update, :delete]) 18 | end 19 | end 20 | 21 | # API # 22 | def todos_api_login_required do 23 | quote do 24 | resources "/todos", Api.TodoController, only: [:index, :create] 25 | end 26 | end 27 | 28 | def todos_require_api_todo_permissions do 29 | quote do 30 | resources "/todos", Api.TodoController, only: [:show, :update, :delete] 31 | end 32 | end 33 | 34 | @doc """ 35 | When used, dispatch the appropriate function by calling the desired function name as an atom. 36 | 37 | ## Examples 38 | 39 | use AccountsRouter, :accounts_allow_any_user 40 | """ 41 | defmacro __using__(which) when is_atom(which) do 42 | apply(__MODULE__, which, []) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /support/scripts/containers/README.md: -------------------------------------------------------------------------------- 1 | # Container Helper Scripts 2 | 3 | This is a collection of scripts designed to save you from having to type out tedious Compose file names when orchestrating your containers. The script names are (hopefully) self-explanatory. 4 | 5 | # Notes 6 | 7 | - The first positional argument must be the `docker-compose` action you want to perform, e.g. up, down, restart, etc. 8 | - When running a Traefik container, your will need to specify the deployment environment as the second positional argument. Must be one of: 9 | - dev - no HTTPS 10 | - staging - uses HTTPS + Let's Encrypt staging environment 11 | - prod - uses HTTPS 12 | - To use Podman instead of Docker, pass the flag '--podman' as the last positional argument. 13 | - Running any of the Traefik containers will attempt to create a `traefik-global-proxy` network before starting the containers. 14 | 15 | ## Troubleshooting 16 | 17 | - If switching between Docker and Podman, you will need to delete the Postgres volume located in: `support/containers/volumes/postgres` 18 | - e.g. `sudo rm -rf support/containers/volumes/postgres` 19 | - You can use the `support/scripts/containers/container-volumes-delete` script to automate the process. 20 | -------------------------------------------------------------------------------- /assets/tests/e2e/base/page.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from "@playwright/test"; 2 | 3 | export abstract class BasePage { 4 | readonly page: Page; 5 | abstract url: URL; 6 | 7 | // // page elements 8 | // readonly flashInfo: Locator; 9 | // readonly flashError: Locator; 10 | // readonly flashDisconnected: Locator; 11 | 12 | // readonly navbar: Locator; 13 | readonly pageTitle: Locator; 14 | readonly toastContainer: Locator; 15 | 16 | constructor(page: Page) { 17 | this.page = page; 18 | 19 | // this.flashDisconnected = page.locator("#flash-disconnected"); 20 | // this.flashError = page.locator("#flash-error"); 21 | // this.flashInfo = page.locator("#flash-info"); 22 | 23 | // this.navbar = page.locator("[data-component='navbar']"); 24 | this.pageTitle = page.locator("#page-title"); // page elements 25 | this.toastContainer = page.locator("#toast-container"); 26 | } 27 | 28 | // actions 29 | async goto() { 30 | await this.page.goto(this.url.toString()); 31 | } 32 | 33 | async toastClearAll() { 34 | /** Clear all toast messages. */ 35 | await this.page.evaluate(() => { 36 | const toastContainerElt = document.querySelector("#toast-container"); 37 | toastContainerElt!.dispatchEvent(new CustomEvent("clear")); 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /assets/tests/unit/README.md: -------------------------------------------------------------------------------- 1 | # Unit Tests - Vitest 2 | 3 | This project uses [`Vitest`](https://vitest.dev/) to manage its Javascript unit tests. 4 | 5 | ## Initial Setup 6 | 7 | NOTE: The commands in this section should be run from the project root directory. 8 | 9 | Before you can run the tests, you must ensure that the npm dependencies have been installed: 10 | 11 | - Ensure npm is installed. (I recommend using `asdf` to install `npm` if necessary.) 12 | - Navigate to the Javascript root directory for this projects: `cd assets` 13 | - Install the dependencies: `npm install --save-dev` 14 | 15 | ## Running the Tests 16 | 17 | To run this project's Javascript unit tests: 18 | 19 | - Run once: 20 | - `just test-unit` 21 | - Or, use one of the alternative methods: 22 | - Navigate to the directory that contains the Javascript-based testing projects and run the tests: 23 | - `cd assets && npm run test-unit` 24 | - Or, `support/scripts/test-unit` 25 | - Run in watch mode: 26 | - `just test-unit-watch` 27 | - Or, use one of the alternative methods: 28 | - `support/scripts/test-unit-watch` 29 | - Or, navigate to the directory that contains the Javascript-based testing projects and run the tests: 30 | - `cd assets && npm run test-unit-watch` 31 | - Or, `support/scripts/test-unit-watch` 32 | -------------------------------------------------------------------------------- /test/loadtest/k6/todo-create.js: -------------------------------------------------------------------------------- 1 | import { check } from "k6"; 2 | import { parseHTML } from "k6/html"; 3 | import http from "k6/http"; 4 | 5 | import login from "./login.js"; 6 | 7 | function csrfTokenGet(response) { 8 | // if no response passed in, get a response by making a generic GET request 9 | if (!response) { 10 | response = http.get(__ENV.BASE_URL); 11 | } 12 | 13 | // get the CSRF token by parsing the tag 14 | const doc = parseHTML(response.body); 15 | const csrfToken = doc 16 | .find("head meta") 17 | .toArray() 18 | .filter((tag) => tag.attr("name") === "csrf-token")[0] 19 | .attr("content"); 20 | 21 | return csrfToken; 22 | } 23 | 24 | export default function () { 25 | const url = `${__ENV.BASE_URL}/todos`; 26 | 27 | // setup 28 | login(); 29 | 30 | // make request 31 | const csrfToken = csrfTokenGet(); 32 | const response = http.post(url, { 33 | _csrf_token: csrfToken, 34 | "todo[content]": new Date().toISOString(), 35 | "todo[is_completed]": Math.random() < 0.5, // random boolean 36 | }); 37 | 38 | // response returns expected result 39 | const allTestsDidPass = check(response, { 40 | "redirects to new URL": (res) => res.url !== url, 41 | }); 42 | 43 | if (!allTestsDidPass) console.log(`\x1b[91m${response.body}\x1b[39m`); 44 | } 45 | -------------------------------------------------------------------------------- /assets/tests/e2e/accounts/login/test.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | import { 4 | passwordInvalid, 5 | passwordValid, 6 | testUserEmail, 7 | } from "tests/support/constants"; 8 | import { AccountsLoginPage } from "./page"; 9 | 10 | test.describe("Account login page", () => { 11 | let testPage: AccountsLoginPage; 12 | 13 | test.beforeEach(async ({ page }) => { 14 | // navigate to test page 15 | testPage = new AccountsLoginPage(page); 16 | await testPage.goto(); 17 | 18 | // ensure that the live socket connection has been established 19 | await expect(testPage.phxConnected).toBeVisible(); 20 | }); 21 | 22 | test("logs in a user", async ({ page }) => { 23 | // perform action 24 | await testPage.login(testUserEmail, passwordValid); 25 | 26 | // page redirects to expected URL 27 | await expect(page).toHaveURL(testPage.urlSuccess.toString()); 28 | 29 | // page contains expected success message 30 | await expect(page.getByText("Logged in successfully")).toBeVisible(); 31 | }); 32 | 33 | test("shows error if auth credentials are invalid", async ({ page }) => { 34 | // perform action 35 | await testPage.login(testUserEmail, passwordInvalid); 36 | 37 | // page contains expected error message 38 | await expect(page.getByText("Invalid email or password")).toBeVisible(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /lib/todo_list_web/base/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.Helpers.Controller do 2 | @moduledoc "Generic controller helper functions." 3 | 4 | import Plug.Conn 5 | 6 | def http_response_403(conn) do 7 | conn 8 | |> put_status(:forbidden) 9 | |> Phoenix.Controller.text("403 Forbidden") 10 | |> halt() 11 | end 12 | 13 | def http_response_404(conn) do 14 | conn 15 | |> put_status(:not_found) 16 | |> put_resp_header("content-type", "text/html") 17 | |> Phoenix.Controller.text("Not Found") 18 | |> halt() 19 | end 20 | 21 | def json_response_400(conn, message \\ "Bad Request") do 22 | conn |> put_status(:bad_request) |> Phoenix.Controller.json(%{message: message}) |> halt() 23 | end 24 | 25 | def json_response_403(conn) do 26 | conn |> put_status(:forbidden) |> Phoenix.Controller.json(%{message: "Forbidden"}) |> halt() 27 | end 28 | end 29 | 30 | defmodule TodoListWeb.Helpers.Ecto do 31 | @moduledoc "Helper functions for Ecto." 32 | 33 | def changeset_errors_to_json(changeset) do 34 | Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> 35 | Regex.replace(~r"%{(\w+)}", msg, fn _, key -> 36 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 37 | end) 38 | end) 39 | end 40 | end 41 | 42 | defmodule TodoListWeb.Helpers.Template do 43 | @moduledoc "Helper functions templates." 44 | end 45 | -------------------------------------------------------------------------------- /priv/static/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | https://phoenix-todo-list.nicholasmoen.com/ 4 | 2023-07-10T23:48:35+00:00 5 | 1.00 6 | 7 | 8 | https://phoenix-todo-list.nicholasmoen.com/users/register 9 | 2023-07-10T23:48:35+00:00 10 | 0.80 11 | 12 | 13 | https://phoenix-todo-list.nicholasmoen.com/users/login 14 | 2023-07-10T23:48:35+00:00 15 | 0.80 16 | 17 | 18 | https://phoenix-todo-list.nicholasmoen.com/terms-of-use 19 | 2023-07-10T23:48:35+00:00 20 | 0.80 21 | 22 | 23 | https://phoenix-todo-list.nicholasmoen.com/privacy-policy 24 | 2023-07-10T23:48:35+00:00 25 | 0.80 26 | 27 | 28 | https://phoenix-todo-list.nicholasmoen.com/contact-us 29 | 2023-07-10T23:48:35+00:00 30 | 0.80 31 | 32 | 33 | https://phoenix-todo-list.nicholasmoen.com/users/reset-password 34 | 2023-07-10T23:48:35+00:00 35 | 0.64 36 | 37 | 38 | -------------------------------------------------------------------------------- /lib/todo_list_web/accounts/live/user_logout_live.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.UserLogoutLive do 2 | use TodoListWeb, :live_view 3 | 4 | def mount(_params, _session, socket) do 5 | {:ok, assign(socket, page_title: "Confirm Logout")} 6 | end 7 | 8 | def render(assigns) do 9 | ~H""" 10 | <%= if assigns[:current_user] do %> 11 | <.header class="my-8 text-center"> 12 | Are you sure you want to log out? 13 | 14 | 15 | <.simple_form for={%{}} id="logout-form"> 16 | <:actions> 17 | <.form_button_cancel /> 18 | <.link href={~p"/users/logout"} id="logout-form-button-submit" method="delete"> 19 | <.form_button tabindex="-1" class="btn-primary" content="Yes" /> 20 | 21 | 22 | 23 | <% else %> 24 | <.header class="my-8 text-center"> 25 | You are not currently logged in. 26 | 27 | 28 | <.simple_form for={%{}} id="logout-form"> 29 | <:actions> 30 | <.link href={~p"/"}> 31 | <.form_button tabindex="-1" class="btn-secondary" content="Home" /> 32 | 33 | <.link href={~p"/users/login"}> 34 | <.form_button tabindex="-1" class="btn-primary" content="Login" /> 35 | 36 | 37 | 38 | <% end %> 39 | """ 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /support/scripts/containers/backups/postgres/backup-create-outside-container: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # shellcheck disable=SC2153 4 | 5 | show_help() { 6 | echo "This script creates a pg_dump backup. It must be run outside the container. 7 | 8 | The backup file will be created in the same directory as this script. It will be created with a timestamp in the filename. 9 | - e.g. 'phoenix-todo-list--pg-dump-2022-11-05-06-23-37.dump' 10 | 11 | This script accepts the following environment variable(s): 12 | - POSTGRES_CONTAINER_NAME: The name of the Postgres container to be backed up 13 | - Default: 'phoenix-todo-list_postgres' 14 | 15 | To use Podman instead of Docker, pass '--podman' as the last positional argument." 16 | } 17 | 18 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 19 | show_help 20 | exit 21 | fi 22 | 23 | # configure postgres container name 24 | if [ "$POSTGRES_CONTAINER_NAME" != "" ]; then 25 | postgres_container_name="$POSTGRES_CONTAINER_NAME" 26 | else 27 | postgres_container_name="phoenix-todo-list-postgres-1" 28 | fi 29 | 30 | # configure container manager 31 | if [ "$1" = "--podman" ]; then 32 | container_manager=podman 33 | else 34 | container_manager=docker 35 | fi 36 | 37 | echo "Creating backup in Postgres container volume directory '/var/lib/postgresql/backups/'..." 38 | 39 | $container_manager exec -it "$postgres_container_name" /var/lib/postgresql/backups/backup-create-inside-container 40 | -------------------------------------------------------------------------------- /lib/todo_list_web/base/router.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.Base.Router do 2 | # BROWSER # 3 | def base_allow_any_user do 4 | quote do 5 | get("/debug", BaseController, :debug) 6 | get("/contact-us", BaseController, :contact_us) 7 | get("/privacy-policy", BaseController, :privacy_policy) 8 | get("/terms-of-use", BaseController, :terms_of_use) 9 | 10 | live("/", HomeLive) 11 | live("/component-showcase", ComponentShowcaseLive) 12 | end 13 | end 14 | 15 | # API # 16 | def base_api_openapi do 17 | quote do 18 | get("/", OpenApiSpex.Plug.RenderSpec, []) 19 | get("/swagger-ui", OpenApiSpex.Plug.SwaggerUI, path: "/api") 20 | end 21 | end 22 | 23 | # DEV # 24 | def base_dev do 25 | quote do 26 | if Application.compile_env(:todo_list, :dev_routes) do 27 | import Phoenix.LiveDashboard.Router 28 | 29 | scope "/dev" do 30 | pipe_through(:browser) 31 | 32 | live_dashboard("/dashboard", metrics: TodoListWeb.Telemetry) 33 | forward "/mailbox", Plug.Swoosh.MailboxPreview 34 | end 35 | end 36 | end 37 | end 38 | 39 | @doc """ 40 | When used, dispatch the appropriate function by calling the desired function name as an atom. 41 | 42 | ## Examples 43 | 44 | use BaseRouter, :base_allow_any_user 45 | """ 46 | defmacro __using__(which) when is_atom(which) do 47 | apply(__MODULE__, which, []) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /assets/tests/e2e/accounts/logout/page.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from "@playwright/test"; 2 | import { BasePage } from "tests/e2e/base/page"; 3 | 4 | import { urls } from "tests/support/constants"; 5 | 6 | export class AccountsLogoutPage extends BasePage { 7 | readonly page: Page; 8 | readonly phxConnected: Locator; 9 | 10 | // URLs 11 | readonly url: URL; 12 | readonly urlSuccess: URL; 13 | 14 | /* page elements */ 15 | readonly formButtonCancel: Locator; 16 | readonly formButtonSubmit: Locator; 17 | readonly formButtonHome: Locator; 18 | readonly formButtonLogin: Locator; 19 | 20 | // selectors 21 | 22 | constructor(page: Page) { 23 | super(page); 24 | this.page = page; 25 | this.phxConnected = this.page.locator("[data-phx-main].phx-connected"); 26 | 27 | // URLs 28 | this.url = new URL(urls.accounts.logout); 29 | this.urlSuccess = new URL(urls.base.index); 30 | 31 | /* form elements */ 32 | const logoutForm = page.locator("#logout-form"); 33 | this.formButtonCancel = logoutForm.locator("button", { hasText: "Cancel" }); 34 | this.formButtonSubmit = logoutForm.locator("#logout-form-button-submit"); 35 | this.formButtonHome = logoutForm.locator("button", { hasText: "Home" }); 36 | this.formButtonLogin = logoutForm.locator("button", { hasText: "Login" }); 37 | } 38 | 39 | // actions 40 | async logout() { 41 | await this.formButtonSubmit.click(); // submit the form 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /support/scripts/containers/backups/postgres/backup-restore-outside-container: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # shellcheck disable=SC2153 4 | 5 | show_help() { 6 | echo "This script restores a pg_dump backup. It must be run outside the container. 7 | 8 | This script accepts the following environment variable(s): 9 | - POSTGRES_CONTAINER_NAME: The name of the Postgres container to be restored 10 | - Default: 'phoenix-todo-list-postgres-1' 11 | 12 | The first positional argument must be the filename of the backup you want to restore. The file must be located in the postgres backup 13 | - DO NOT pass in the full path. Just the filename. 14 | - e.g. 'phoenix-todo-list--pg-dump-2022-11-05-06-23-37.dump' 15 | 16 | To use Podman instead of Docker, pass '--podman' as the last positional argument." 17 | } 18 | 19 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 20 | show_help 21 | exit 22 | elif [ "$1" = "" ]; then 23 | show_help 24 | exit 2 25 | fi 26 | 27 | # configure postgres container name 28 | if [ "$POSTGRES_CONTAINER_NAME" != "" ]; then 29 | postgres_container_name="$POSTGRES_CONTAINER_NAME" 30 | else 31 | postgres_container_name="phoenix-todo-list-postgres-1" 32 | fi 33 | 34 | # configure container manager 35 | if [ "$1" = "--podman" ]; then 36 | container_manager=podman 37 | else 38 | container_manager=docker 39 | fi 40 | 41 | $container_manager exec -it "$postgres_container_name" /var/lib/postgresql/backups/backup-restore-inside-container "$1" 42 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # This file excludes paths from the Docker build context. 2 | # 3 | # By default, Docker's build context includes all files (and folders) in the 4 | # current directory. Even if a file isn't copied into the container it is still sent to 5 | # the Docker daemon. 6 | # 7 | # There are multiple reasons to exclude files from the build context: 8 | # 9 | # 1. Prevent nested folders from being copied into the container (ex: exclude 10 | # /assets/node_modules when copying /assets) 11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) 12 | # 3. Avoid sending files containing sensitive information 13 | # 14 | # More information on using .dockerignore is available here: 15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 16 | 17 | .dockerignore 18 | 19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed: 20 | # 21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat 22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc 23 | .git 24 | !.git/HEAD 25 | !.git/refs 26 | 27 | # Common development/test artifacts 28 | /cover/ 29 | /doc/ 30 | /test/ 31 | /tmp/ 32 | .elixir_ls 33 | 34 | # Mix artifacts 35 | /_build/ 36 | /deps/ 37 | *.ez 38 | 39 | # Generated on crash by the VM 40 | erl_crash.dump 41 | 42 | # Static artifacts - These should be fetched and built inside the Docker image 43 | /assets/node_modules/ 44 | /priv/static/assets/ 45 | /priv/static/cache_manifest.json 46 | -------------------------------------------------------------------------------- /lib/todo_list_web/accounts/live/user_login_live.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.UserLoginLive do 2 | use TodoListWeb, :live_view 3 | 4 | @page_title "Login" 5 | 6 | def render(assigns) do 7 | ~H""" 8 | <.simple_form 9 | :let={f} 10 | id="login_form" 11 | for={%{}} 12 | action={~p"/users/login"} 13 | as={:user} 14 | phx-update="ignore" 15 | > 16 | <.input field={{f, :email}} type="email" label="Email" required /> 17 | <.input field={{f, :password}} type="password" label="Password" required /> 18 | 19 | <:actions :let={f}> 20 |
21 | <.input 22 | field={{f, :remember_me}} 23 | type="checkbox" 24 | class="text-lg" 25 | label="Remember me" 26 | checked 27 | /> 28 |
29 | 30 | 31 | <:actions> 32 | <.form_button_cancel /> 33 | <.form_button_submit /> 34 | 35 | 36 | 37 | <.action_links 38 | class="mt-16" 39 | items={[ 40 | %{content: "Register new account", href: ~p"/users/register"}, 41 | %{content: "Forgot your password?", href: ~p"/users/reset-password"} 42 | ]} 43 | /> 44 | """ 45 | end 46 | 47 | def mount(_params, _session, socket) do 48 | email = live_flash(socket.assigns.flash, :email) 49 | {:ok, assign(socket, page_title: @page_title, email: email), temporary_assigns: [email: nil]} 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/todo_list_web/accounts/live/user_confirmation_instructions_live.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.UserConfirmationInstructionsLive do 2 | use TodoListWeb, :live_view 3 | 4 | alias TodoList.Accounts 5 | 6 | def render(assigns) do 7 | ~H""" 8 | <.simple_form 9 | :let={f} 10 | for={%{}} 11 | as={:user} 12 | id="resend_confirmation_form" 13 | phx-submit="send_instructions" 14 | > 15 | <.input field={{f, :email}} type="email" label="Email" required /> 16 | <:actions> 17 | <.button phx-disable-with="Sending...">Resend confirmation instructions 18 | 19 | 20 | 21 |

22 | <.link href={~p"/users/register"}>Register 23 | | <.link href={~p"/users/login"}>Log in 24 |

25 | """ 26 | end 27 | 28 | def mount(_params, _session, socket) do 29 | {:ok, assign(socket, page_title: "Resend Confirmation Instructions")} 30 | end 31 | 32 | def handle_event("send_instructions", %{"user" => %{"email" => email}}, socket) do 33 | if user = Accounts.get_user_by_email(email) do 34 | Accounts.deliver_user_confirmation_instructions( 35 | user, 36 | &url(~p"/users/confirm/#{&1}") 37 | ) 38 | end 39 | 40 | info = 41 | "If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly." 42 | 43 | {:noreply, 44 | socket 45 | |> put_flash(:info, info) 46 | |> redirect(to: ~p"/")} 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /assets/tests/e2e/support/setup/global.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { FullConfig, chromium } from "@playwright/test"; 3 | 4 | import { AccountsRegisterPage } from "tests/e2e/accounts/register/page"; 5 | import { storageState } from "tests/e2e/support/constants"; 6 | import { testUserEmail, passwordValid } from "tests/support/constants"; 7 | import { textColorize } from "tests/support/helpers"; 8 | 9 | // expose shared state so that setup data can be accessed in teardown logic 10 | export const state: { baseUrl?: string; sqlSandboxUserAgent?: string } = {}; 11 | 12 | async function globalSetup(config: FullConfig) { 13 | state.baseUrl = config.projects[0].use.baseURL; 14 | 15 | // create storageState.json if it doesn't already exist 16 | if (!fs.existsSync(storageState)) { 17 | console.log( 18 | textColorize(`File '${storageState}' doesn't exist. Creating it now...`) 19 | ); 20 | 21 | fs.writeFileSync(storageState, "{}"); // create empty JSON file 22 | } 23 | 24 | // create new browser session (use shared state so teardown can access browser context) 25 | const browser = await chromium.launch(); 26 | const page = await browser.newPage({ storageState: undefined }); 27 | 28 | // register generic test user 29 | console.log(textColorize("Setup (global): Registering generic test user...")); 30 | const accountsRegisterPage = new AccountsRegisterPage(page); 31 | await accountsRegisterPage.goto(); 32 | await accountsRegisterPage.register(testUserEmail, passwordValid); 33 | } 34 | 35 | export default globalSetup; 36 | -------------------------------------------------------------------------------- /lib/todo_list_web/accounts/live/user_forgot_password_live.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.UserForgotPasswordLive do 2 | use TodoListWeb, :live_view 3 | 4 | alias TodoList.Accounts 5 | 6 | def render(assigns) do 7 | ~H""" 8 |
9 | Complete this form, then check your email inbox. 10 |
11 | 12 | <.simple_form :let={f} id="reset_password_form" for={%{}} as={:user} phx-submit="send_email"> 13 | <.input field={{f, :email}} type="email" placeholder="Your email" required /> 14 | <:actions> 15 | <.form_button_cancel /> 16 | <.form_button_submit /> 17 | 18 | 19 | 20 | <.action_links items={[ 21 | %{content: "Register new account", href: ~p"/users/register"}, 22 | %{content: "Login", href: ~p"/users/login"} 23 | ]} /> 24 | """ 25 | end 26 | 27 | def mount(_params, _session, socket) do 28 | {:ok, assign(socket, page_title: "Forgot Your Password?")} 29 | end 30 | 31 | def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do 32 | if user = Accounts.get_user_by_email(email) do 33 | Accounts.deliver_user_reset_password_instructions( 34 | user, 35 | &url(~p"/users/reset-password/#{&1}") 36 | ) 37 | end 38 | 39 | info = 40 | "If your email is in our system, then we have sent an email to your inbox " <> 41 | "containing password reset instructions." 42 | 43 | {:noreply, 44 | socket 45 | |> put_flash(:info, info) 46 | |> redirect(to: ~p"/")} 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /assets/tests/support/constants.ts: -------------------------------------------------------------------------------- 1 | import process from "process"; 2 | 3 | // base URL 4 | const phxHost = process.env.PHX_HOST; 5 | const port = Number(process.env.PORT) + 2; 6 | export const baseUrl = `http://${phxHost}:${port}`; 7 | 8 | export const urls = { 9 | base: { 10 | index: new URL(baseUrl + "/"), 11 | }, 12 | accounts: { 13 | delete: new URL(baseUrl + "/users/profile/delete"), 14 | login: new URL(baseUrl + "/users/login"), 15 | logout: new URL(baseUrl + "/users/logout"), 16 | register: new URL(baseUrl + "/users/register"), 17 | profile: new URL(baseUrl + "/users/profile"), 18 | update: new URL(baseUrl + "/users/profile/update"), 19 | updateEmail: new URL(baseUrl + "/users/profile/update/email"), 20 | updatePassword: new URL(baseUrl + "/users/profile/update/password"), 21 | }, 22 | todos: { 23 | todosLive: new URL(baseUrl + "/todos/live"), 24 | }, 25 | }; 26 | 27 | export const phoenix = { 28 | sessionValidityDuration: 1000 * 60 * 60 * 24 * 60, // 60 days 29 | }; 30 | 31 | export const emailInvalid = "emailInvalid"; 32 | 33 | export const errors = { 34 | email: { 35 | isTaken: "This email address is already in use.", 36 | isInvalid: "This is not a valid email address.", 37 | }, 38 | password: { 39 | isTooShort: "Must have 8 or more character(s)", 40 | }, 41 | passwordConfirmation: { 42 | doesNotMatch: "The passwords do not match.", 43 | }, 44 | }; 45 | 46 | export const testUserEmail = "test_user@example.com"; 47 | export const passwordInvalid = "passwordInvalid"; 48 | export const passwordValid = "passwordValid"; 49 | -------------------------------------------------------------------------------- /lib/todo_list_web/accounts/controllers/user_session_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.UserSessionController do 2 | use TodoListWeb, :controller 3 | 4 | alias TodoList.Accounts 5 | alias TodoListWeb.UserAuth 6 | 7 | def create(conn, %{"_action" => "registered"} = params) do 8 | create(conn, params, "Account created successfully") 9 | end 10 | 11 | def create(conn, %{"_action" => "password_updated"} = params) do 12 | conn 13 | |> put_session(:user_return_to, ~p"/users/profile") 14 | |> create(params, "Password updated successfully") 15 | end 16 | 17 | def create(conn, params) do 18 | create(conn, params, "Logged in successfully") 19 | end 20 | 21 | defp create(conn, %{"user" => user_params}, success_message) do 22 | %{"email" => email, "password" => password} = user_params 23 | 24 | if user = Accounts.get_user_by_email_and_password(email, password) do 25 | conn 26 | |> put_flash(:info, success_message) 27 | |> UserAuth.login_user(user, user_params) 28 | else 29 | conn 30 | |> put_flash(:error, "Invalid email or password") 31 | |> put_flash(:email, String.slice(email, 0, 160)) 32 | |> redirect(to: ~p"/users/login") 33 | end 34 | end 35 | 36 | def show(conn, _params) do 37 | render(conn, :show, page_title: "Your Profile") 38 | end 39 | 40 | def update(conn, _params) do 41 | conn |> render(:update, page_title: "Manage Your Profile") 42 | end 43 | 44 | def delete(conn, _params) do 45 | conn 46 | |> put_flash(:info, "Logged out successfully") 47 | |> UserAuth.logout_user() 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /support/scripts/containers/compose--phoenix: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 4 | echo "This script manages a Compose service for Phoenix. 5 | 6 | The first positional argument must specify the 'docker-compose' command(s) to run. 7 | - Examples: up, down, restart, etc. 8 | 9 | To use Podman instead of Docker, pass the '--podman' flag as the last positional argument." 10 | exit 11 | fi 12 | 13 | # navigate to containers directory 14 | project_root_directory="$(dirname "$0")/../../.." 15 | containers_directory="$project_root_directory/support/containers" 16 | cd "$containers_directory" || exit 1 17 | 18 | # ensure first positional argument is present 19 | if [ "$1" = "" ]; then 20 | echo "The first positional argument must specify the 'docker-compose' command(s) to run. Examples: up, down, restart, etc. 21 | 22 | To use Podman instead of Docker, pass the '--podman' flag as the last positional argument." 23 | exit 1 24 | else 25 | # move the first arg into a variable and shift it out of args 26 | action=$1 27 | shift 28 | fi 29 | 30 | # get last arg 31 | for last_arg; do true; done 32 | 33 | # determine which container manager to use ('docker compose' or podman-compose) 34 | application_to_run="docker compose" 35 | 36 | if [ "$last_arg" = "--podman" ]; then 37 | # use podman-compose instead of docker 38 | application_to_run="podman-compose" 39 | 40 | # remove last arg 41 | set -- "${@:1:$(($# - 1))}" 42 | fi 43 | 44 | # run container action 45 | # shellcheck disable=SC2068,SC2086 46 | $application_to_run -f compose.phoenix.yaml -f networks/compose.phoenix-host.yaml $action $@ 47 | -------------------------------------------------------------------------------- /lib/todo_list_web/base/controllers/base_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.BaseController do 2 | use TodoListWeb, :controller 3 | 4 | def debug(conn, params) do 5 | # use query params for testing/debugging backend features 6 | cond do 7 | params["sentry_error"] == "1" -> 8 | # generate and send an example error to sentry 9 | try do 10 | raise RuntimeError, message: "Example Error for Sentry" 11 | rescue 12 | err -> 13 | Sentry.capture_exception(err, 14 | stacktrace: __STACKTRACE__, 15 | extra: %{extra_info: "Extra information"} 16 | ) 17 | end 18 | 19 | # return 404 after sending error to sentry 20 | conn |> TodoListWeb.Helpers.Controller.http_response_404() 21 | 22 | params["error"] == "403" -> 23 | conn |> TodoListWeb.Helpers.Controller.http_response_403() 24 | 25 | params["error"] == "404" -> 26 | conn |> TodoListWeb.Helpers.Controller.http_response_404() 27 | 28 | params["error"] == "500" -> 29 | raise RuntimeError, message: "Example Error" 30 | 31 | true -> 32 | # return 404 by default 33 | conn |> TodoListWeb.Helpers.Controller.http_response_404() 34 | end 35 | end 36 | 37 | def contact_us(conn, _params) do 38 | render(conn, :contact_us, page_title: "Contact Us") 39 | end 40 | 41 | def privacy_policy(conn, _params) do 42 | render(conn, :privacy_policy, page_title: "Privacy Policy") 43 | end 44 | 45 | def terms_of_use(conn, _params) do 46 | render(conn, :terms_of_use, page_title: "Terms of Use") 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /support/scripts/containers/compose--postgres: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 4 | echo "This script manages a Compose service for Phoenix + Postgres + Traefik. 5 | 6 | The first positional argument must specify the 'docker-compose' command(s) to run. 7 | - Examples: up, down, restart, etc. 8 | 9 | To use Podman instead of Docker, pass the '--podman' flag as the last positional argument." 10 | exit 11 | fi 12 | 13 | # navigate to containers directory 14 | project_root_directory="$(dirname "$0")/../../.." 15 | containers_directory="$project_root_directory/support/containers" 16 | cd "$containers_directory" || exit 1 17 | 18 | # ensure first positional argument is present 19 | if [ "$1" = "" ]; then 20 | echo "The first positional argument must specify the 'docker-compose' command(s) to run. Examples: up, down, restart, etc. 21 | 22 | To use Podman instead of Docker, pass the '--podman' flag as the last positional argument." 23 | exit 1 24 | else 25 | # move the first arg into a variable and shift it out of args 26 | action=$1 27 | shift 28 | fi 29 | 30 | # get last arg 31 | for last_arg; do true; done 32 | 33 | # determine which container manager to use ('docker compose' or podman-compose) 34 | application_to_run="docker compose" 35 | if [ "$last_arg" = "--podman" ]; then 36 | # use podman-compose instead of docker 37 | application_to_run="podman-compose" 38 | 39 | # remove last arg 40 | set -- "${@:1:$(($# - 1))}" 41 | fi 42 | 43 | # run container action 44 | # shellcheck disable=SC2068,SC2086 45 | $application_to_run -f compose.postgres.yaml -f networks/compose.postgres-host.yaml $action $@ 46 | -------------------------------------------------------------------------------- /lib/todo_list/application.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoList.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | Logger.add_backend(Sentry.LoggerBackend) 11 | 12 | children = [ 13 | # Start the Telemetry supervisor 14 | TodoListWeb.Telemetry, 15 | # Start the Ecto repository 16 | TodoList.Repo, 17 | # Start the PubSub system 18 | {Phoenix.PubSub, name: TodoList.PubSub}, 19 | TodoList.Presence, 20 | # Start Finch 21 | {Finch, name: TodoList.Finch}, 22 | # Start the Endpoint (http/https) 23 | TodoListWeb.Endpoint, 24 | # Start a worker by calling: TodoList.Worker.start_link(arg) 25 | # {TodoList.Worker, arg} 26 | { 27 | TodoList.PromEx, 28 | plugins: [ 29 | {PromEx.Plugins.Application, [otp_app: :todo_list]}, 30 | {PromEx.Plugins.Phoenix, router: TodoListWeb.Router, endpoint: TodoListWeb.Endpoint} 31 | ], 32 | delay_manual_start: :no_delay 33 | } 34 | ] 35 | 36 | # See https://hexdocs.pm/elixir/Supervisor.html 37 | # for other strategies and supported options 38 | opts = [strategy: :one_for_one, name: TodoList.Supervisor] 39 | Supervisor.start_link(children, opts) 40 | end 41 | 42 | # Tell Phoenix to update the endpoint configuration 43 | # whenever the application is updated. 44 | @impl true 45 | def config_change(changed, _new, removed) do 46 | TodoListWeb.Endpoint.config_change(changed, removed) 47 | :ok 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Only in tests, remove the complexity from the password hashing algorithm 4 | config :bcrypt_elixir, :log_rounds, 1 5 | 6 | # Configure your database 7 | # 8 | # The MIX_TEST_PARTITION environment variable can be used 9 | # to provide built-in test partitioning in CI environment. 10 | # Run `mix help test` for more information. 11 | config :todo_list, TodoList.Repo, 12 | username: "postgres", 13 | password: "postgres", 14 | hostname: "localhost", 15 | database: "todo_list_test#{System.get_env("MIX_TEST_PARTITION")}", 16 | pool: Ecto.Adapters.SQL.Sandbox, 17 | pool_size: 10 18 | 19 | port = String.to_integer(System.get_env("PORT") || "4000") + 2 20 | 21 | # We don't run a server during test. If one is required, 22 | # you can enable the server option below. 23 | config :todo_list, TodoListWeb.Endpoint, 24 | http: [ip: {0, 0, 0, 0}, port: port], 25 | secret_key_base: "Ygp6edO5FTklaUFXjkSnuF7y8alcXyb/cU/J1BZH34cvOANEO/U+37Q7hpGR+3ff", 26 | server: true, 27 | check_origin: false 28 | 29 | # In test we don't send emails. 30 | config :todo_list, TodoList.Mailer, adapter: Swoosh.Adapters.Test 31 | 32 | # Disable swoosh api client as it is only required for production adapters. 33 | config :swoosh, :api_client, false 34 | 35 | # Print only warnings and errors during test 36 | config :logger, level: :warning 37 | 38 | # Initialize plugs at runtime for faster test compilation 39 | config :phoenix, :plug_init_mode, :runtime 40 | 41 | # enable sandbox for concurrent E2E tests 42 | config :todo_list, sql_sandbox: true 43 | 44 | # HACK: enable dummy forms for testing live views 45 | config :todo_list, hack_test_lv_dummy_forms_enabled: true 46 | -------------------------------------------------------------------------------- /support/scripts/dotenv-generate: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 4 | echo "This script bootstraps a copy of .env using any currently-exported environment variables. 5 | 6 | Optional flags: 7 | --force - Overwrite an existing dotenv file 8 | --vagrant - Generate params for deployment in a Vagrant host" 9 | exit 10 | fi 11 | 12 | project_root_directory="$(dirname "$0")/../.." 13 | cd "$project_root_directory" || exit 1 14 | 15 | dotenv_path="$(pwd)/.env" 16 | 17 | # get last arg 18 | for last_arg; do true; done 19 | 20 | if [ -f "$dotenv_path" ]; then 21 | 22 | if [ "$last_arg" = "--force" ]; then 23 | # import existing dotenv 24 | echo "Importing existing environment from '$dotenv_path'" 25 | set -o allexport 26 | # shellcheck source=/dev/null 27 | . "$dotenv_path" 28 | set +o allexport 29 | 30 | # we will overwrite the dotenv file 31 | echo "Overwriting existing dotenv file: '$dotenv_path'..." 32 | 33 | else 34 | # exit without writing the dotenv file 35 | printf "\033[31mDotenv file already exists: '%s'.\033[39m\nPass '--force' as the last positional argument to overwrite this file.\n" "$dotenv_path" 36 | echo "Aborting..." 37 | exit 1 38 | fi 39 | else 40 | echo "Generating local environment: '$dotenv_path'..." 41 | fi 42 | 43 | dotenv_template_path="$(pwd)/support/scripts/dotenv-generate--template" 44 | output_string="$($dotenv_template_path "$@")" 45 | 46 | if [ "$DRY_RUN" = 1 ]; then 47 | echo "$output_string" 48 | else 49 | # create the .env file 50 | echo "$output_string" >"$dotenv_path" 51 | 52 | # set permissions on the newly-created .env file 53 | chmod 600 "$dotenv_path" 54 | fi 55 | -------------------------------------------------------------------------------- /lib/todo_list_web/base/plugs.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.Plug do 2 | @moduledoc """ 3 | This project's custom plugs 4 | """ 5 | import Plug.Conn 6 | 7 | alias TodoList.Todos 8 | alias TodoListWeb.Helpers.Controller, as: ControllerHelpers 9 | 10 | @doc """ 11 | Get a Todo by the `id` param and add it to the `conn`. 12 | """ 13 | def fetch_todo(conn, _opts) do 14 | assign(conn, :todo, Todos.get_todo!(conn.params["id"])) 15 | end 16 | 17 | @doc """ 18 | Ensure that the requesting user is the creator of this Todo. 19 | """ 20 | def require_todo_permissions(conn, _opts) do 21 | todo = conn.assigns.todo 22 | 23 | if todo.user_id == conn.assigns.current_user.id do 24 | conn 25 | else 26 | conn |> ControllerHelpers.http_response_403() 27 | end 28 | end 29 | 30 | @doc """ 31 | Ensure that the requesting user is the creator of this Todo. 32 | """ 33 | def require_api_todo_permissions(conn, _opts) do 34 | todo = conn.assigns.todo 35 | 36 | if todo.user_id == conn.assigns.current_user.id do 37 | conn 38 | else 39 | conn |> ControllerHelpers.json_response_403() 40 | end 41 | end 42 | 43 | @doc """ 44 | Ensure that the ID of the requesting user matches the `id` param. 45 | """ 46 | def require_api_user_permissions(conn, _opts) do 47 | try do 48 | user_id = String.to_integer(conn.params["id"]) 49 | 50 | if user_id == conn.assigns.current_user.id do 51 | conn 52 | else 53 | conn |> ControllerHelpers.json_response_403() 54 | end 55 | rescue 56 | _err in ArgumentError -> 57 | conn |> ControllerHelpers.json_response_400("Invalid user ID detected") 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /support/scripts/containers/compose--grafana: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 4 | echo "This script manages a Compose service for Grafana. 5 | 6 | The first positional argument must specify the 'docker-compose' command(s) to run. 7 | - Examples: up, down, restart, etc. 8 | 9 | Features: 10 | - Runs with host networking 11 | - Accessible via port 3000 12 | - Uses Docker by default 13 | - To use Podman instead of Docker, pass the '--podman' flag as the last positional argument." 14 | exit 15 | fi 16 | 17 | # navigate to containers directory 18 | project_root_directory="$(dirname "$0")/../../.." 19 | containers_directory="$project_root_directory/support/containers" 20 | cd "$containers_directory" || exit 1 21 | 22 | # ensure first positional argument is present 23 | if [ "$1" = "" ]; then 24 | echo "The first positional argument must specify the 'docker-compose' command(s) to run. Examples: up, down, restart, etc. 25 | 26 | To use Podman instead of Docker, pass the '--podman' flag as the last positional argument." 27 | exit 1 28 | else 29 | # move the first arg into a variable and shift it out of args 30 | action=$1 31 | shift 32 | fi 33 | 34 | # get last arg 35 | for last_arg; do true; done 36 | 37 | # determine which container manager to use ('docker compose' or podman-compose) 38 | application_to_run="docker compose" 39 | 40 | if [ "$last_arg" = "--podman" ]; then 41 | # use podman-compose instead of docker 42 | application_to_run="podman-compose" 43 | 44 | # remove last arg 45 | set -- "${@:1:$(($# - 1))}" 46 | fi 47 | 48 | # run container action 49 | # shellcheck disable=SC2068,SC2086 50 | $application_to_run -f compose.grafana.yaml $action $@ 51 | -------------------------------------------------------------------------------- /support/scripts/containers/compose--prometheus: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 4 | echo "This script manages a Compose service for Prometheus. 5 | 6 | The first positional argument must specify the 'docker-compose' command(s) to run. 7 | - Examples: up, down, restart, etc. 8 | 9 | Features: 10 | - Runs with host networking 11 | - Accessible via port 9090 12 | - Uses Docker by default 13 | - To use Podman instead of Docker, pass the '--podman' flag as the last positional argument." 14 | exit 15 | fi 16 | 17 | # navigate to containers directory 18 | project_root_directory="$(dirname "$0")/../../.." 19 | containers_directory="$project_root_directory/support/containers" 20 | cd "$containers_directory" || exit 1 21 | 22 | # ensure first positional argument is present 23 | if [ "$1" = "" ]; then 24 | echo "The first positional argument must specify the 'docker-compose' command(s) to run. Examples: up, down, restart, etc. 25 | 26 | To use Podman instead of Docker, pass the '--podman' flag as the last positional argument." 27 | exit 1 28 | else 29 | # move the first arg into a variable and shift it out of args 30 | action=$1 31 | shift 32 | fi 33 | 34 | # get last arg 35 | for last_arg; do true; done 36 | 37 | # determine which container manager to use ('docker compose' or podman-compose) 38 | application_to_run="docker compose" 39 | 40 | if [ "$last_arg" = "--podman" ]; then 41 | # use podman-compose instead of docker 42 | application_to_run="podman-compose" 43 | 44 | # remove last arg 45 | set -- "${@:1:$(($# - 1))}" 46 | fi 47 | 48 | # run container action 49 | # shellcheck disable=SC2068,SC2086 50 | $application_to_run -f compose.prometheus.yaml $action $@ 51 | -------------------------------------------------------------------------------- /support/scripts/systemd-container-service-teardown: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | service_name="phoenix-todo-list" 4 | 5 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 6 | echo "This script stops and removes this project's user-level systemd service file. 7 | 8 | It does the following: 9 | - Stops the user-level systemd service 10 | - Disables the user-level systemd service 11 | - Deletes the user-level systemd service file 12 | - Reloads the user-level systemd daemons 13 | 14 | To prevent accidental service file deletion, enter 'ok' (without the quotes) as the first positional parameter when running this script." 15 | exit 16 | elif [ "$1" != "ok" ]; then 17 | echo "Must enter 'ok' (without the quotes) as the first positional parameter to continue. Aborting..." 18 | exit 1 19 | fi 20 | 21 | # navigate to current directory 22 | cd "$(dirname "$0")" || exit 1 23 | 24 | # stop the user-level systemd service 25 | echo "Stopping the user-level systemd service '$service_name'..." 26 | systemctl --user stop $service_name 27 | 28 | # disable the user-level systemd service 29 | echo "Disabling the user-level systemd service '$service_name'..." 30 | systemctl --user disable $service_name 31 | 32 | # delete the user-level systemd service file 33 | service_file_path=$HOME/.config/systemd/user/$service_name.service 34 | echo "Deleting the user-level systemd unit file from '$service_file_path'..." 35 | rm "$service_file_path" 36 | 37 | # reload the systemd daemons 38 | echo "Reloading the user-level systemd daemons..." 39 | systemctl --user daemon-reload 40 | 41 | # remove the container management script 42 | echo "Removing the manual container management script..." 43 | rm "$(dirname "$0")/../containers/container-management-script.gitignored" 44 | -------------------------------------------------------------------------------- /support/scripts/containers/compose--phoenix-postgres: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 4 | echo "This script manages a Compose service for Phoenix + Postgres. 5 | 6 | The first positional argument must specify the 'docker-compose' command(s) to run. 7 | - Examples: up, down, restart, etc. 8 | 9 | To use Podman instead of Docker, pass the '--podman' flag as the last positional argument." 10 | exit 11 | fi 12 | 13 | # navigate to containers directory 14 | project_root_directory="$(dirname "$0")/../../.." 15 | containers_directory="$project_root_directory/support/containers" 16 | cd "$containers_directory" || exit 1 17 | 18 | # ensure first positional argument is present 19 | if [ "$1" = "" ]; then 20 | echo "The first positional argument must specify the 'docker-compose' command(s) to run. Examples: up, down, restart, etc. 21 | 22 | To use Podman instead of Docker, pass the '--podman' flag as the last positional argument." 23 | exit 1 24 | else 25 | # move the first arg into a variable and shift it out of args 26 | action=$1 27 | shift 28 | fi 29 | 30 | # get last arg 31 | for last_arg; do true; done 32 | 33 | # determine which container manager to use ('docker compose' or podman-compose) 34 | application_to_run="docker compose" 35 | 36 | if [ "$last_arg" = "--podman" ]; then 37 | # use podman-compose instead of docker 38 | application_to_run="podman-compose" 39 | 40 | # remove last arg 41 | set -- "${@:1:$(($# - 1))}" 42 | fi 43 | 44 | # run container action 45 | # shellcheck disable=SC2068,SC2086 46 | $application_to_run -f compose.phoenix.yaml -f networks/compose.phoenix-host.yaml -f compose.phoenix-postgres.yaml -f compose.postgres.yaml -f networks/compose.postgres-host.yaml $action $@ 47 | -------------------------------------------------------------------------------- /support/scripts/systemd-container-service-bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | service_name="phoenix-todo-list" 4 | 5 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 6 | echo "This script bootstraps this project's user-level systemd service file. 7 | 8 | It does the following: 9 | - Runs the user-level systemd service file generator (for containers) 10 | - Reloads the user-level systemd daemons 11 | - Enables the service on startup 12 | - NOTE: Lingering must be enabled by the superuser: 'sudo loginctl enable-linger $USER' 13 | - Starts the service 14 | 15 | For more information about the service file generator script, run './systemd-container-service-file-generate --help'" 16 | exit 17 | fi 18 | 19 | # navigate to current directory 20 | cd "$(dirname "$0")" || exit 1 21 | 22 | # run the systemd service file generator script 23 | echo "Generating the systemd service file..." 24 | if ! ./systemd-container-service-file-generate "$@"; then 25 | echo "\033[91mCould not create the systemd service file.\033[39m" 26 | exit 1 27 | fi 28 | 29 | # reload the systemd daemons 30 | echo "Reloading the user-level systemd daemons..." 31 | systemctl --user daemon-reload 32 | 33 | # enable the service on startup 34 | echo "Enabling the systemd service on startup (if service doesn't work on startup, enable lingering for this user)..." 35 | systemctl --user enable $service_name.service 36 | 37 | # (re)start the service 38 | echo "(Re)starting the systemd service..." 39 | systemctl --user restart $service_name.service 40 | 41 | echo " 42 | If this service uses Traefik, you may need to create a container network for Traefik. 43 | - e.g. Docker: 'docker network create traefik-global-proxy' 44 | - e.g. Podman: 'podman network create traefik-global-proxy' 45 | 46 | done" 47 | -------------------------------------------------------------------------------- /support/scripts/containers/compose--prometheus-grafana: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 4 | echo "This script manages a Compose service for Prometheus. 5 | 6 | The first positional argument must specify the 'docker-compose' command(s) to run. 7 | - Examples: up, down, restart, etc. 8 | 9 | Features: 10 | - Runs with host networking 11 | - Accessible via port 9090 12 | - Uses Docker by default 13 | - To use Podman instead of Docker, pass the '--podman' flag as the last positional argument." 14 | exit 15 | fi 16 | 17 | # navigate to containers directory 18 | project_root_directory="$(dirname "$0")/../../.." 19 | containers_directory="$project_root_directory/support/containers" 20 | cd "$containers_directory" || exit 1 21 | 22 | # ensure first positional argument is present 23 | if [ "$1" = "" ]; then 24 | echo "The first positional argument must specify the 'docker-compose' command(s) to run. Examples: up, down, restart, etc. 25 | 26 | To use Podman instead of Docker, pass the '--podman' flag as the last positional argument." 27 | exit 1 28 | else 29 | # move the first arg into a variable and shift it out of args 30 | action=$1 31 | shift 32 | fi 33 | 34 | # get last arg 35 | for last_arg; do true; done 36 | 37 | # determine which container manager to use ('docker compose' or podman-compose) 38 | application_to_run="docker compose" 39 | 40 | if [ "$last_arg" = "--podman" ]; then 41 | # use podman-compose instead of docker 42 | application_to_run="podman-compose" 43 | 44 | # remove last arg 45 | set -- "${@:1:$(($# - 1))}" 46 | fi 47 | 48 | # run container action 49 | # shellcheck disable=SC2068,SC2086 50 | $application_to_run -f compose.prometheus.yaml -f compose.grafana.yaml $action $@ 51 | -------------------------------------------------------------------------------- /test/support/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.Test.GenericTests do 2 | @moduledoc "Reusable tests" 3 | 4 | defmacro test_requires_authenticated_api_user(url, method \\ "get") do 5 | quote do 6 | test "returns 401 for unauthenticated API user: #{unquote(method)}", %{conn: conn} do 7 | url = unquote(url) 8 | method = unquote(method) 9 | 10 | # ensure user is unauthenticated 11 | conn = logout_api_user(conn) 12 | 13 | # make request 14 | conn = 15 | case method do 16 | "get" -> get(conn, url) 17 | "post" -> post(conn, url) 18 | "put" -> put(conn, url) 19 | "patch" -> patch(conn, url) 20 | "delete" -> delete(conn, url) 21 | end 22 | 23 | # response has expected status code and body 24 | assert conn.status == 401 25 | assert conn.resp_body =~ "This endpoint is only accessible to authenticated users." 26 | end 27 | end 28 | end 29 | 30 | defmacro forbids_unpermissioned_api_user(conn, url, method \\ "get") do 31 | quote do 32 | conn = unquote(conn) 33 | url = unquote(url) 34 | method = unquote(method) 35 | 36 | # logout and login as a new user 37 | conn = logout_api_user(conn) 38 | conn = register_and_login_api_user(%{conn: conn}) |> Map.get(:conn) 39 | 40 | # make request 41 | conn = 42 | case method do 43 | "get" -> get(conn, url) 44 | "post" -> post(conn, url) 45 | "put" -> put(conn, url) 46 | "patch" -> patch(conn, url) 47 | "delete" -> delete(conn, url) 48 | end 49 | 50 | # response has expected status code and body 51 | assert conn.status == 403 52 | assert conn.resp_body =~ "Forbidden" 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoList.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | Postgres, you can even run database tests asynchronously 13 | by setting `use TodoList.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias TodoList.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import TodoList.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | TodoList.DataCase.setup_sandbox(tags) 32 | :ok 33 | end 34 | 35 | @doc """ 36 | Sets up the sandbox based on the test tags. 37 | """ 38 | def setup_sandbox(tags) do 39 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(TodoList.Repo, shared: not tags[:async]) 40 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 41 | end 42 | 43 | @doc """ 44 | A helper that transforms changeset errors into a map of messages. 45 | 46 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 47 | assert "password is too short" in errors_on(changeset).password 48 | assert %{password: ["password is too short"]} = errors_on(changeset) 49 | 50 | """ 51 | def errors_on(changeset) do 52 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 53 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 54 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 55 | end) 56 | end) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /assets/tests/e2e/accounts/login/page.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from "@playwright/test"; 2 | 3 | import { BasePage } from "tests/e2e/base/page"; 4 | import { urls } from "tests/support/constants"; 5 | 6 | export class AccountsLoginPage extends BasePage { 7 | readonly page: Page; 8 | readonly phxConnected: Locator; 9 | 10 | // URLs 11 | readonly url: URL; 12 | readonly urlSuccess: URL; 13 | 14 | // page elements 15 | readonly inputEmail: Locator; 16 | readonly inputErrorEmail: Locator; 17 | 18 | readonly inputPassword: Locator; 19 | readonly inputErrorPassword: Locator; 20 | 21 | readonly formButtonSubmit: Locator; 22 | 23 | constructor(page: Page) { 24 | super(page); 25 | this.page = page; 26 | this.phxConnected = this.page.locator("[data-phx-main].phx-connected"); 27 | 28 | // URLs 29 | this.url = new URL(urls.accounts.login); 30 | this.urlSuccess = new URL(urls.todos.todosLive); 31 | 32 | // form elements 33 | this.inputEmail = page.locator("input[name='user[email]']"); 34 | this.inputErrorEmail = page.locator( 35 | "[phx-feedback-for='user[email]'] [data-component='error']" 36 | ); 37 | 38 | this.inputPassword = page.locator("input[name='user[password]']"); 39 | this.inputErrorPassword = page.locator( 40 | "[phx-feedback-for='user[password]'] [data-component='error']" 41 | ); 42 | 43 | this.formButtonSubmit = page 44 | .locator("#login_form") 45 | .locator("button[type='submit']"); 46 | } 47 | 48 | async login(email: string, password: string, options = { submit: true }) { 49 | // fill out the form 50 | await this.inputEmail.click(); 51 | await this.inputEmail.fill(email); 52 | 53 | await this.inputPassword.click(); 54 | await this.inputPassword.fill(password); 55 | 56 | if (options.submit) { 57 | // submit the form 58 | await this.formButtonSubmit.click(); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /support/scripts/caddyfile-generate.placeholder: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | 4 | if "-h" in sys.argv or "--help" in sys.argv: 5 | print( 6 | """This script is a placeholder for a future Caddyfile generator script. 7 | 8 | Flags: 9 | -a --append - Append the output to an existing Caddyfile 10 | - Used for allowing multiple domains in a single Caddyfile 11 | 12 | -d --domain - Specify a custom domain name (default: $PHX_HOST) 13 | -f --force - Overwrite the existing Caddyfile if one exists 14 | ---insecure-metrics - Do not secure the '/metrics' endpoint 15 | -p --port - Specify a custom port (default: $PORT) 16 | --password - The password to secure the '/metrics' endpoint with 17 | - Will read the password via a non-echoing interactive prompt 18 | - One of the password flags must be passed unless '--insecure-metrics' is passed 19 | - The password will be hashed using `mkpasswd --method=bcrypt` 20 | - Can't be used with '--insecure-metrics' 21 | --raw-password - The password to secure the '/metrics' endpoint with 22 | - The password must be passed when calling the script. 23 | - Take care to ensure that the password is not added to the shell history! 24 | - One of the password flags must be passed unless '--insecure-metrics' is passed 25 | - The password will be hashed using `mkpasswd --method=bcrypt` 26 | - Can't be used with '--insecure-metrics' 27 | --test-cert - Use Let's Encrypt test certificates 28 | - Can't be used with '--vagrant' 29 | -vagrant - Configure for use in a Vagrant host (adds path to self-signed keys) 30 | - Adds line to vagrant: `tls /vagrant/cert.pem /vagrant/key.pem` 31 | - Can't be used with '--test-cert'""" 32 | ) 33 | sys.exit() 34 | 35 | print( 36 | """This script is a placeholder for a future Caddyfile generator script. 37 | For more info, run this script with the '--help' flag.""" 38 | ) 39 | -------------------------------------------------------------------------------- /assets/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | 4 | const plugin = require("tailwindcss/plugin"); 5 | 6 | module.exports = { 7 | content: ["./js/**/*.js", "../lib/*_web.ex", "../lib/*_web/**/*.*ex"], 8 | theme: { 9 | extend: { 10 | colors: { 11 | brand: "#FD4F00", 12 | }, 13 | }, 14 | // listStyleType: { 15 | // dash: "- ", 16 | // }, 17 | }, 18 | daisyui: { 19 | logs: false, 20 | themes: [ 21 | { 22 | default: { 23 | primary: "#116FFD", 24 | secondary: "#5E656C", 25 | accent: "#116FFD", 26 | neutral: "#3B68AB", 27 | "base-100": "#EEF5FF", 28 | info: "#97C0FE", 29 | success: "#198754", 30 | warning: "#FFC107", 31 | error: "#DC3545", 32 | }, 33 | dark: { 34 | primary: "#116FFD", 35 | secondary: "#5E656C", 36 | accent: "#116FFD", 37 | neutral: "#3B68AB", 38 | "base-100": "#001026", 39 | info: "#97C0FE", 40 | success: "#198754", 41 | warning: "#FFC107", 42 | error: "#DC3545", 43 | }, 44 | }, 45 | ], 46 | }, 47 | plugins: [ 48 | // require("@tailwindcss/forms"), 49 | require("@tailwindcss/typography"), 50 | plugin(({ addVariant }) => 51 | addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"]) 52 | ), 53 | plugin(({ addVariant }) => 54 | addVariant("phx-click-loading", [ 55 | ".phx-click-loading&", 56 | ".phx-click-loading &", 57 | ]) 58 | ), 59 | plugin(({ addVariant }) => 60 | addVariant("phx-submit-loading", [ 61 | ".phx-submit-loading&", 62 | ".phx-submit-loading &", 63 | ]) 64 | ), 65 | plugin(({ addVariant }) => 66 | addVariant("phx-change-loading", [ 67 | ".phx-change-loading&", 68 | ".phx-change-loading &", 69 | ]) 70 | ), 71 | require("daisyui"), 72 | ], 73 | }; 74 | -------------------------------------------------------------------------------- /assets/tests/support/helpers.ts: -------------------------------------------------------------------------------- 1 | export enum ConsoleColors { 2 | // colors 3 | Default = "default", 4 | Gray = "gray", 5 | Cyan = "cyan", 6 | Green = "green", 7 | Yellow = "yellow", 8 | Red = "red", 9 | 10 | // themes 11 | Primary = "primary", 12 | Secondary = "secondary", 13 | Info = "info", 14 | Success = "success", 15 | Warning = "warning", 16 | Error = "error", 17 | } 18 | 19 | export function textColorize( 20 | content: string, 21 | color: ConsoleColors | string = ConsoleColors.Info, 22 | bold: boolean = false 23 | ) { 24 | /** Return a string of text with ANSI formatting escape codes. */ 25 | let colorCode = "\x1b["; // sequence begins the escape code 26 | const colorResetCode = "\x1b[0m"; // resets terminal text to default color 27 | 28 | // parse color code from specified color 29 | switch (color) { 30 | case ConsoleColors.Default: 31 | case ConsoleColors.Primary: 32 | colorCode += "39"; 33 | break; 34 | case ConsoleColors.Gray: 35 | case ConsoleColors.Secondary: 36 | colorCode += "90"; 37 | break; 38 | case ConsoleColors.Cyan: 39 | case ConsoleColors.Info: 40 | colorCode += "96"; 41 | break; 42 | case ConsoleColors.Green: 43 | case ConsoleColors.Success: 44 | colorCode += "92"; 45 | break; 46 | case ConsoleColors.Yellow: 47 | case ConsoleColors.Warning: 48 | colorCode += "93"; 49 | break; 50 | case ConsoleColors.Red: 51 | case ConsoleColors.Error: 52 | colorCode += "91"; 53 | break; 54 | default: 55 | throw ( 56 | `Invalid color parameter received (${color}). Must be one of: ` + 57 | "default, gray, cyan, green, success, yellow, red, " + 58 | "primary, secondary, info, success, warning, error" 59 | ); 60 | } 61 | 62 | if (bold) colorCode += ";1"; // append bold formatting 63 | colorCode += "m"; // append the letter 'm' to complete the code 64 | 65 | // prepend format data and append 66 | content = `${colorCode}${content}${colorResetCode}`; 67 | 68 | return content; 69 | } 70 | -------------------------------------------------------------------------------- /lib/todo_list_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.Endpoint do 2 | use Sentry.PlugCapture 3 | use Phoenix.Endpoint, otp_app: :todo_list 4 | 5 | # The session will be stored in the cookie and signed, 6 | # this means its contents can be read but not tampered with. 7 | # Set :encryption_salt if you would also like to encrypt it. 8 | @session_options [ 9 | store: :cookie, 10 | key: "_todo_list_key", 11 | signing_salt: "MkNUzqam", 12 | same_site: "Lax" 13 | ] 14 | 15 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 16 | 17 | # enable SQL sandbox for concurrent E2E tests 18 | if Application.compile_env(:todo_list, :sql_sandbox) do 19 | plug Phoenix.Ecto.SQL.Sandbox, 20 | at: "/sandbox", 21 | repo: TodoList.Repo, 22 | timeout: 60_000 23 | end 24 | 25 | # Serve at "/" the static files from "priv/static" directory. 26 | # 27 | # You should set gzip to true if you are running phx.digest 28 | # when deploying your static files in production. 29 | plug Plug.Static, 30 | at: "/", 31 | from: :todo_list, 32 | gzip: false, 33 | only: TodoListWeb.static_paths() 34 | 35 | # Code reloading can be explicitly enabled under the 36 | # :code_reloader configuration of your endpoint. 37 | if code_reloading? do 38 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 39 | plug Phoenix.LiveReloader 40 | plug Phoenix.CodeReloader 41 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :todo_list 42 | end 43 | 44 | plug Phoenix.LiveDashboard.RequestLogger, 45 | param_key: "request_logger", 46 | cookie_key: "request_logger" 47 | 48 | plug PromEx.Plug, prom_ex_module: TodoList.PromEx 49 | 50 | plug Plug.RequestId 51 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 52 | 53 | plug Plug.Parsers, 54 | parsers: [:urlencoded, :multipart, :json], 55 | pass: ["*/*"], 56 | json_decoder: Phoenix.json_library() 57 | 58 | plug Sentry.PlugContext 59 | 60 | plug Plug.MethodOverride 61 | plug Plug.Head 62 | plug Plug.Session, @session_options 63 | plug TodoListWeb.Router 64 | end 65 | -------------------------------------------------------------------------------- /support/scripts/dotenv-generate--template: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 4 | echo "Outputs template whose output can be piped to an environment file. 5 | 6 | This script is typically run by the 'dotenv-generate' script. 7 | 8 | NOTE: To use this script's default values, any matching environment variables 9 | must first be unset. 10 | 11 | Optional flags: 12 | --force - Overwrite an existing dotenv file (handled by 'dotenv-generate') 13 | --vagrant - Generate params for deployment in a Vagrant host" 14 | exit 15 | fi 16 | 17 | # parse args 18 | while test $# -gt 0; do 19 | case "$1" in 20 | --vagrant) 21 | # configure environment for deployment in a Vagrant host 22 | is_vagrant=1 23 | ;; 24 | esac 25 | shift 26 | done 27 | 28 | if [ "$is_vagrant" = "1" ]; then 29 | # configure environment for use with vagrant 30 | PHX_HOST=phoenix-todo-list.localhost 31 | TRAEFIK_DASHBOARD_FQDN=localhost 32 | fi 33 | 34 | default_phx_host=phoenix-todo-list.localhost 35 | 36 | echo "# phoenix 37 | PHX_HOST=\"${PHX_HOST:-$default_phx_host}\" 38 | DEPLOYMENT_ENVIRONMENT=\"${DEPLOYMENT_ENVIRONMENT:-dev}\" 39 | PORT=\"${PORT:-4000}\" 40 | SECRET_KEY_BASE=\"${SECRET_KEY_BASE:-$(openssl rand -base64 48)}\" 41 | 42 | # database 43 | POSTGRES_USER=\"${POSTGRES_USER:-postgres}\" 44 | POSTGRES_PASSWORD=\"${POSTGRES_PASSWORD:-postgres}\" 45 | POSTGRES_HOST=\"${POSTGRES_HOST:-localhost}\" 46 | POSTGRES_DB=\"${POSTGRES_DB:-todo_list}\" 47 | DATABASE_URL=\"${DATABASE_URL:-ecto://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@${POSTGRES_HOST:-localhost}/${POSTGRES_DB:-todo_list}}\" 48 | 49 | # docker 50 | COMPOSE_PROJECT_NAME=\"${COMPOSE_PROJECT_NAME:-phoenix-todo-list}\" 51 | IMAGE_TAG=\"${IMAGE_TAG:-$(uname -m)}\" 52 | 53 | # email 54 | AWS_ACCESS_KEY=\"${AWS_ACCESS_KEY:-your_aws_access_key}\" 55 | AWS_REGION=\"${AWS_REGION:-your_aws_region_eg_us-east-1}\" 56 | AWS_SECRET=\"${AWS_SECRET}\" 57 | EMAIL_FROM_DEFAULT=\"${EMAIL_FROM_DEFAULT:-no-reply@${PHX_HOST:-$default_phx_host}}\" 58 | 59 | # sentry 60 | SENTRY_DSN=\"${SENTRY_DSN}\" 61 | 62 | # traefik 63 | TRAEFIK_DASHBOARD_FQDN=\"${TRAEFIK_DASHBOARD_FQDN}\"" 64 | -------------------------------------------------------------------------------- /test/todo_list_web/live/user_forgot_password_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.UserForgotPasswordLiveTest do 2 | @moduledoc false 3 | 4 | use TodoListWeb.ConnCase 5 | 6 | import Phoenix.LiveViewTest 7 | import TodoList.AccountsFixtures 8 | 9 | alias TodoList.Accounts 10 | alias TodoList.Repo 11 | 12 | describe "Forgot password page" do 13 | test "renders email page", %{conn: conn} do 14 | {:ok, _lv, html} = live(conn, ~p"/users/reset-password") 15 | 16 | assert html =~ "Forgot Your Password?" 17 | assert html =~ "Register" 18 | assert html =~ "Log in" 19 | end 20 | 21 | test "redirects if already logged in", %{conn: conn} do 22 | result = 23 | conn 24 | |> login_user(user_fixture()) 25 | |> live(~p"/users/reset-password") 26 | |> follow_redirect(conn, ~p"/todos/live") 27 | 28 | assert {:ok, _conn} = result 29 | end 30 | end 31 | 32 | describe "Reset link" do 33 | setup do 34 | %{user: user_fixture()} 35 | end 36 | 37 | test "sends a new reset password token", %{conn: conn, user: user} do 38 | {:ok, lv, _html} = live(conn, ~p"/users/reset-password") 39 | 40 | {:ok, conn} = 41 | lv 42 | |> form("#reset_password_form", user: %{"email" => user.email}) 43 | |> render_submit() 44 | |> follow_redirect(conn, "/") 45 | 46 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" 47 | 48 | assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == 49 | "reset_password" 50 | end 51 | 52 | test "does not send reset password token if email is invalid", %{conn: conn} do 53 | {:ok, lv, _html} = live(conn, ~p"/users/reset-password") 54 | 55 | {:ok, conn} = 56 | lv 57 | |> form("#reset_password_form", user: %{"email" => "unknown@example.com"}) 58 | |> render_submit() 59 | |> follow_redirect(conn, "/") 60 | 61 | assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" 62 | assert Repo.all(Accounts.UserToken) == [] 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/todo_list/accounts/user_notifier.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoList.Accounts.UserNotifier do 2 | @moduledoc false 3 | import Swoosh.Email 4 | 5 | alias TodoList.Mailer 6 | 7 | # Delivers the email using the application mailer. 8 | defp deliver(recipient, subject, body) do 9 | email = 10 | new() 11 | |> to(recipient) 12 | |> from( 13 | {"TodoList", 14 | System.get_env("EMAIL_FROM_DEFAULT") || 15 | "no-reply@" <> System.get_env("PHX_HOST", "localhost")} 16 | ) 17 | |> subject(subject) 18 | |> text_body(body) 19 | 20 | with {:ok, _metadata} <- Mailer.deliver(email) do 21 | {:ok, email} 22 | end 23 | end 24 | 25 | @doc """ 26 | Deliver instructions to confirm account. 27 | """ 28 | def deliver_confirmation_instructions(user, url) do 29 | deliver(user.email, "Confirmation instructions", """ 30 | 31 | ============================== 32 | 33 | Hi #{user.email}, 34 | 35 | You can confirm your account by visiting the URL below: 36 | 37 | #{url} 38 | 39 | If you didn't create an account with us, please ignore this. 40 | 41 | ============================== 42 | """) 43 | end 44 | 45 | @doc """ 46 | Deliver instructions to reset a user password. 47 | """ 48 | def deliver_reset_password_instructions(user, url) do 49 | deliver(user.email, "Reset password instructions", """ 50 | 51 | ============================== 52 | 53 | Hi #{user.email}, 54 | 55 | You can reset your password by visiting the URL below: 56 | 57 | #{url} 58 | 59 | If you didn't request this change, please ignore this. 60 | 61 | ============================== 62 | """) 63 | end 64 | 65 | @doc """ 66 | Deliver instructions to update a user email. 67 | """ 68 | def deliver_update_email_instructions(user, url) do 69 | deliver(user.email, "Update email instructions", """ 70 | 71 | ============================== 72 | 73 | Hi #{user.email}, 74 | 75 | You can change your email by visiting the URL below: 76 | 77 | #{url} 78 | 79 | If you didn't request this change, please ignore this. 80 | 81 | ============================== 82 | """) 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /test/loadtest/k6/login.js: -------------------------------------------------------------------------------- 1 | import { check } from "k6"; 2 | import { parseHTML } from "k6/html"; 3 | import http from "k6/http"; 4 | 5 | function csrfTokenGet(response) { 6 | // if no response passed in, get a response by making a generic GET request 7 | if (!response) { 8 | response = http.get(__ENV.BASE_URL); 9 | } 10 | 11 | // get the CSRF token by parsing the tag 12 | const doc = parseHTML(response.body); 13 | const csrfToken = doc 14 | .find("head meta") 15 | .toArray() 16 | .filter((tag) => tag.attr("name") === "csrf-token")[0] 17 | .attr("content"); 18 | 19 | return csrfToken; 20 | } 21 | 22 | export default function () { 23 | const url = `${__ENV.BASE_URL}/users/login`; 24 | const urlSuccess = `${__ENV.BASE_URL}/todos/live`; 25 | 26 | console.log(`Using login URL '${url}'...`); 27 | 28 | const email = __ENV.EMAIL || "user@example.com"; 29 | console.log(`Using email '${email}'...`); 30 | if (!__ENV.EMAIL) 31 | console.log( 32 | "\x1b[96mTo use a custom email address, set the 'EMAIL' environment variable.\x1b[39m" 33 | ); 34 | 35 | const password = __ENV.PASSWORD || "password"; 36 | console.log(`Using password '${password}'...`); 37 | if (!__ENV.PASSWORD) 38 | console.log( 39 | "\x1b[96mTo use a custom password, set the 'PASSWORD' environment variable.\x1b[39m" 40 | ); 41 | 42 | // submit GET request to base URL so we can get a session cookie + CSRF token 43 | let response = http.get(url); 44 | check(response, { 45 | "status is 200": (res) => res.status === 200, 46 | }); 47 | 48 | // get CSRF token 49 | const csrfToken = csrfTokenGet(response); 50 | 51 | // post successful login form data 52 | response = http.post(url, { 53 | _csrf_token: csrfToken, 54 | "user[email]": email, 55 | "user[password]": password, 56 | "user[remember_me]": "false", 57 | }); 58 | 59 | // response returns expected result 60 | const allTestsDidPass = check(response, { 61 | "redirects to different URL after successful login": (res) => 62 | res.url === urlSuccess, 63 | }); 64 | 65 | // error handling 66 | if (!allTestsDidPass) { 67 | throw `\x1b[91m*** Could not authenticate user. Has this user been registered? ***\x1b[39m`; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/todo_list_web/todos/controllers/todo_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.TodoController do 2 | use TodoListWeb, :controller 3 | 4 | alias TodoList.Todos 5 | alias TodoList.Todos.Todo 6 | 7 | def index(conn, params) do 8 | with {:ok, {todos, meta}} <- 9 | Todos.list_todos_by_user_id(params, conn.assigns.current_user.id) do 10 | render(conn, :index, page_title: "Your Todo List", meta: meta, todos: todos) 11 | end 12 | end 13 | 14 | def new(conn, _params) do 15 | changeset = Todos.change_todo(%Todo{}) 16 | render(conn, :new, changeset: changeset, page_title: "Create Todo") 17 | end 18 | 19 | def create(conn, %{"todo" => todo_params}) do 20 | # set user_id to current user 21 | todo_params = Map.merge(todo_params, %{"user_id" => conn.assigns.current_user.id}) 22 | 23 | case Todos.create_todo(todo_params) do 24 | {:ok, todo} -> 25 | conn 26 | |> put_flash(:info, "Todo created successfully") 27 | |> redirect(to: ~p"/todos/#{todo}") 28 | 29 | {:error, %Ecto.Changeset{} = changeset} -> 30 | render(conn, :new, page_title: "Create Todo", changeset: changeset) 31 | end 32 | end 33 | 34 | def show(conn, _params) do 35 | conn 36 | |> render(:show, page_title: "Todo Info (##{conn.assigns.todo.id})", todo: conn.assigns.todo) 37 | end 38 | 39 | def edit(conn, _params) do 40 | todo = conn.assigns.todo 41 | changeset = Todos.change_todo(todo) 42 | render(conn, :edit, page_title: "Edit Todo", todo: todo, changeset: changeset) 43 | end 44 | 45 | def update(conn, %{"todo" => todo_params}) do 46 | todo = conn.assigns.todo 47 | 48 | case Todos.update_todo(todo, todo_params) do 49 | {:ok, _todo} -> 50 | conn 51 | |> put_flash(:info, "Todo updated successfully") 52 | |> redirect(to: ~p"/todos") 53 | 54 | {:error, %Ecto.Changeset{} = changeset} -> 55 | render(conn, :edit, page_title: "Edit Todo", todo: todo, changeset: changeset) 56 | end 57 | end 58 | 59 | def delete(conn, _params) do 60 | todo = conn.assigns.todo 61 | {:ok, _todo} = Todos.delete_todo(todo) 62 | 63 | conn 64 | |> put_flash(:info, "Todo deleted successfully") 65 | |> redirect(to: ~p"/todos") 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/todo_list_web/accounts/live/user_confirmation_live.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoListWeb.UserConfirmationLive do 2 | use TodoListWeb, :live_view 3 | 4 | alias TodoList.Accounts 5 | 6 | def render(%{live_action: :edit} = assigns) do 7 | ~H""" 8 |
9 | <.header class="text-center">Confirm Account 10 | 11 | <.simple_form :let={f} for={%{}} as={:user} id="confirmation_form" phx-submit="confirm_account"> 12 | <.input field={{f, :token}} type="hidden" value={@token} /> 13 | <:actions> 14 | <.button phx-disable-with="Confirming..." class="w-full">Confirm my account 15 | 16 | 17 | 18 |

19 | <.link href={~p"/users/register"}>Register 20 | | <.link href={~p"/users/login"}>Log in 21 |

22 |
23 | """ 24 | end 25 | 26 | def mount(params, _session, socket) do 27 | {:ok, assign(socket, page_title: "Confirm Account", token: params["token"]), 28 | temporary_assigns: [token: nil]} 29 | end 30 | 31 | # Do not log in the user after confirmation to avoid a 32 | # leaked token giving the user access to the account. 33 | def handle_event("confirm_account", %{"user" => %{"token" => token}}, socket) do 34 | case Accounts.confirm_user(token) do 35 | {:ok, _} -> 36 | {:noreply, 37 | socket 38 | |> put_flash(:info, "Your account is now confirmed.") 39 | |> redirect(to: ~p"/")} 40 | 41 | :error -> 42 | # If there is a current user and the account was already confirmed, 43 | # then odds are that the confirmation link was already visited, either 44 | # by some automation or by the user themselves, so we redirect without 45 | # a warning message. 46 | case socket.assigns do 47 | %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> 48 | {:noreply, redirect(socket, to: ~p"/")} 49 | 50 | %{} -> 51 | {:noreply, 52 | socket 53 | |> put_flash(:error, "User confirmation link is invalid or it has expired.") 54 | |> redirect(to: ~p"/")} 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :todo_list, 11 | ecto_repos: [TodoList.Repo] 12 | 13 | # Configures the endpoint 14 | config :todo_list, TodoListWeb.Endpoint, 15 | url: [host: "localhost"], 16 | render_errors: [ 17 | formats: [html: TodoListWeb.ErrorHTML, json: TodoListWeb.ErrorJSON], 18 | layout: false 19 | ], 20 | pubsub_server: TodoList.PubSub, 21 | live_view: [signing_salt: "fhzPBZom"] 22 | 23 | # Configures the mailer 24 | # 25 | # By default it uses the "Local" adapter which stores the emails 26 | # locally. You can see the emails in your browser, at "/dev/mailbox". 27 | # 28 | # For production it's recommended to configure a different adapter 29 | # at the `config/runtime.exs`. 30 | config :todo_list, TodoList.Mailer, adapter: Swoosh.Adapters.Local 31 | 32 | # Configure esbuild (the version is required) 33 | config :esbuild, 34 | version: "0.17.19", 35 | default: [ 36 | args: 37 | ~w(js/app.js js/init/page.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 38 | cd: Path.expand("../assets", __DIR__), 39 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 40 | ] 41 | 42 | # Configure tailwind (the version is required) 43 | config :tailwind, 44 | version: "3.3.2", 45 | default: [ 46 | args: ~w( 47 | --config=tailwind.config.cjs 48 | --input=css/app.css 49 | --output=../priv/static/assets/app.css 50 | ), 51 | cd: Path.expand("../assets", __DIR__) 52 | ] 53 | 54 | # Configures Elixir's Logger 55 | config :logger, :console, 56 | format: "$time $metadata[$level] $message\n", 57 | metadata: [:request_id] 58 | 59 | # Use Jason for JSON parsing in Phoenix 60 | config :phoenix, :json_library, Jason 61 | 62 | # Import environment specific config. This must remain at the bottom 63 | # of this file so it overrides the configuration defined above. 64 | import_config "#{config_env()}.exs" 65 | 66 | # flop 67 | config :flop, repo: TodoList.Repo 68 | 69 | config :sentry, 70 | client: Sentry.FinchClient 71 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.1.16] 9 | 10 | ### Changed 11 | 12 | - Bump Docker base image to 'elixir:1.15.5-erlang-26.0.2-debian-bookworm-20230612-slim' 13 | - Bump max supported OTP version: 25.3 -> 26.0.2 14 | 15 | ## [0.1.15] 16 | 17 | ### Changed 18 | 19 | - Disallow uncrawlable URLs in 'robots.txt' 20 | 21 | ## [0.1.14] 22 | 23 | ### Changed 24 | 25 | - Use self-closing `