diff --git a/.drone.yml b/.drone.yml index 990a8bf..80e0b68 100644 --- a/.drone.yml +++ b/.drone.yml @@ -4,69 +4,39 @@ name: check services: - name: postgres - image: docker.io/library/postgres:17.2 + image: docker.io/library/postgres:17.5 environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres + - name: docker + image: docker:dind + privileged: true + volumes: + - name: dockersock + path: /var/run trigger: event: - push steps: - - name: lint - image: docker.io/library/elixir:1.18.3-otp-27 - commands: - # Install hex package manager - - mix local.hex --force - # Fetch dependencies - - mix deps.get - # Check for compilation errors & warnings - - mix compile --warnings-as-errors - # Check formatting - - mix format --check-formatted - # Security checks - - mix sobelow --config - # Check dependencies for known vulnerabilities - - mix deps.audit - # Check for dependencies that are not maintained anymore - - mix hex.audit - # Provide hints for improving code quality - - mix credo - - - name: wait_for_postgres - image: docker.io/library/postgres:17.2 - commands: - # Wait for postgres to become available - - | - for i in {1..20}; do - if pg_isready -h postgres -U postgres; then - exit 0 - else - true - fi - sleep 2 - done - echo "Postgres did not become available, aborting." - exit 1 - - - name: test - image: docker.io/library/elixir:1.18.3-otp-27 - environment: - MIX_ENV: test - TEST_POSTGRES_HOST: postgres - commands: - # Install hex package manager - - mix local.hex --force - # Fetch dependencies - - mix deps.get - # Run tests - - mix test - - name: build & publish container? - image: docker.io/library/elixir:1.18.3-otp-27 + image: docker:dind + volumes: + - name: dockersock + path: /var/run commands: - - docker build --tag mitgliederverwaltung . + - sleep 5 # give docker time to start + - docker ps -a + - docker build --tag mitgliederverwaltung . + +volumes: + - name: cache + host: + path: /tmp/drone_cache + + - name: dockersock + temp: {} --- kind: pipeline @@ -86,13 +56,13 @@ environment: steps: - name: renovate - image: renovate/renovate:40.22 + image: renovate/renovate:41.37 environment: RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_TOKEN: from_secret: RENOVATE_TOKEN - #GITHUB_COM_TOKEN: - # from_secret: GITHUB_COM_TOKEN + GITHUB_COM_TOKEN: + from_secret: GITHUB_COM_TOKEN commands: # https://github.com/renovatebot/renovate/discussions/15049 - unset GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL diff --git a/.formatter.exs b/.formatter.exs index 75c5c0c..11132c0 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,7 +1,9 @@ [ import_deps: [ + :ash_authentication_phoenix, :ash_admin, :ash_postgres, + :ash_authentication, :ash_phoenix, :ash, :reactor, diff --git a/.gitignore b/.gitignore index 247777e..eef8464 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ mv-*.tar npm-debug.log /assets/node_modules/ +.cursor + +# Ignore the .env file with env variables +.env diff --git a/.igniter.exs b/.igniter.exs new file mode 100644 index 0000000..bdc3383 --- /dev/null +++ b/.igniter.exs @@ -0,0 +1,10 @@ +# This is a configuration file for igniter. +# For option documentation, see https://hexdocs.pm/igniter/Igniter.Project.IgniterConfig.html +# To keep it up to date, use `mix igniter.setup` +[ + module_location: :outside_matching_folder, + extensions: [{Igniter.Extensions.Phoenix, []}], + deps_location: :last_list_literal, + source_folders: ["lib", "test/support"], + dont_move_files: [~r"lib/mix"] +] diff --git a/.tool-versions b/.tool-versions index cbe11b5..682ce54 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ elixir 1.18.3-otp-27 -erlang 27.3 -just 1.40.0 +erlang 27.3.4 +just 1.42.2 diff --git a/Justfile b/Justfile index b3541fd..26db3bc 100644 --- a/Justfile +++ b/Justfile @@ -1,4 +1,6 @@ -run: install-dependencies start-database migrate-database +set dotenv-load := true + +run: install-dependencies start-database migrate-database seed-database mix phx.server install-dependencies: @@ -9,12 +11,20 @@ migrate-database: reset-database: mix ash.reset + MIX_ENV=test mix ash.reset + +seed-database: + mix run priv/repo/seeds.exs start-database: docker compose up -d ci-dev: lint audit test +gettext: + mix gettext.extract + mix gettext.merge priv/gettext + lint: mix format --check-formatted mix compile --warnings-as-errors @@ -25,7 +35,7 @@ audit: mix deps.audit mix hex.audit -test: +test: install-dependencies start-database mix test format: @@ -36,4 +46,35 @@ build-docker-container: # This is meant for debugging the container build process only. run-docker-container: build-docker-container - docker run -e "SECRET_KEY_BASE=ahK8BeiDaibaige1ahkooS0chie9lo7the7uuzar0eeBeeCh2iereteshee2Oosu" -e='DATABASE_URL=postgres://postgres@localhost:5432/mv_dev' -e='PORT=4040' -e='PHX_HOST=localhost' --network=host mitgliederverwaltung \ No newline at end of file + docker run -e "SECRET_KEY_BASE=ahK8BeiDaibaige1ahkooS0chie9lo7the7uuzar0eeBeeCh2iereteshee2Oosu" -e='DATABASE_URL=postgres://postgres@localhost:5432/mv_dev' -e='PORT=4040' -e='PHX_HOST=localhost' --network=host mitgliederverwaltung + +# Usage: +# just regen-migrations migration_name [commit_hash] +# If commit_hash is given, rollback & delete the migrations from that commit. +# Otherwise, rollback & delete all untracked migrations. +regen-migrations migration_name commit_hash='': + #!/usr/bin/env bash + set -euo pipefail + # Pick migrations either from the given commit or untracked files + if [ -n "{{commit_hash}}" ]; then + echo "→ Rolling back migrations from commit {{commit_hash}}" + MIG_FILES=$(git show --name-only --pretty=format: "{{commit_hash}}" \ + | grep -E "^priv/repo/migrations/|^priv/resource_snapshots") + else + echo "→ Rolling back all untracked migrations" + MIG_FILES=$(git ls-files --others priv/repo/migrations) + fi + + # Roll back in Ash + COUNT=$(echo "$MIG_FILES" | wc -l) + mix ash_postgres.rollback -n "$COUNT" + + # Remove the migration files + echo removing $MIG_FILES + echo "$MIG_FILES" | xargs rm -f + + # Also clean up any untracked resource snapshots + git ls-files --others priv/resource_snapshots | xargs rm -f + + # Generate a fresh migration + mix ash.codegen --name "{{migration_name}}" diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index c16fe48..873d6d6 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -7,6 +7,7 @@ const path = require("path") module.exports = { content: [ + "../deps/ash_authentication_phoenix/**/*.*ex", "./js/**/*.js", "../lib/mv_web.ex", "../lib/mv_web/**/*.*ex" diff --git a/config/config.exs b/config/config.exs index a43af46..43c8cf8 100644 --- a/config/config.exs +++ b/config/config.exs @@ -49,7 +49,7 @@ config :spark, config :mv, ecto_repos: [Mv.Repo], generators: [timestamp_type: :utc_datetime], - ash_domains: [Mv.Membership] + ash_domains: [Mv.Membership, Mv.Accounts] # Configures the endpoint config :mv, MvWeb.Endpoint, diff --git a/config/dev.exs b/config/dev.exs index b7f9ad7..17b4ce1 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -84,3 +84,19 @@ config :phoenix_live_view, # Disable swoosh api client as it is only required for production adapters. config :swoosh, :api_client, false + +config :mv, :secret_key_base, "ryn7D6ssmIHQFWIks2sFiTGATgwwAR1+3bN8p7fy6qVtB8qnxOuk1uyAwHz1Q8WB" + +# Signing Secret for Authentication +config :mv, :token_signing_secret, "IwUwi65TrEeExwBXXFPGm2I7889NsL" + +config :mv, :rauthy, + client_id: "mv", + base_url: "http://localhost:8080/auth/v1", + client_secret: System.get_env("OIDC_CLIENT_SECRET"), + redirect_uri: "http://localhost:4000/auth/user/rauthy/callback" + +# AshAuthentication development configuration +config :mv, :session_identifier, :jti + +config :mv, :require_token_presence_for_authentication, true diff --git a/config/runtime.exs b/config/runtime.exs index e591590..e8ab249 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -53,6 +53,13 @@ if config_env() == :prod do config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") + config :mv, :rauthy, redirect_uri: "http://localhost:4000/auth/user/rauthy/callback" + + # AshAuthentication production configuration + config :mv, :session_identifier, :jti + + config :mv, :require_token_presence_for_authentication, true + config :mv, MvWeb.Endpoint, url: [host: host, port: 443, scheme: "https"], http: [ diff --git a/config/test.exs b/config/test.exs index 00a6a7f..bcb55eb 100644 --- a/config/test.exs +++ b/config/test.exs @@ -9,6 +9,7 @@ config :mv, Mv.Repo, username: "postgres", password: "postgres", hostname: System.get_env("TEST_POSTGRES_HOST", "localhost"), + port: System.get_env("TEST_POSTGRES_PORT", "5000"), database: "mv_test#{System.get_env("MIX_TEST_PARTITION")}", pool: Ecto.Adapters.SQL.Sandbox, pool_size: System.schedulers_online() * 2 @@ -35,3 +36,12 @@ config :phoenix, :plug_init_mode, :runtime # Enable helpful, but potentially expensive runtime checks config :phoenix_live_view, enable_expensive_runtime_checks: true + +# Token signing secret for AshAuthentication tests +config :mv, :token_signing_secret, "test_secret_key_for_ash_authentication_tokens" + +# AshAuthentication test-specific configuration +# In Tests we don't need token presence, but in other envs its recommended +config :mv, :session_identifier, :unsafe + +config :mv, :require_token_presence_for_authentication, false diff --git a/docker-compose.yml b/docker-compose.yml index 0ac02ca..fabe6b1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,13 @@ version: "3.5" +networks: + local: + rauthy-dev: + driver: bridge + services: db: - image: postgres:17.2-alpine + image: postgres:17.5-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -18,8 +23,46 @@ services: networks: - local -networks: - local: + mailcrab: + image: marlonb/mailcrab:latest + ports: + - "1080:1080" + networks: + - rauthy-dev + + + rauthy: + container_name: rauthy-dev + image: ghcr.io/sebadob/rauthy:0.31.2 + environment: + - LOCAL_TEST=true + - SMTP_URL=mailcrab + - SMTP_PORT=1025 + - SMTP_DANGER_INSECURE=true + - LISTEN_SCHEME=http + - PUB_URL=localhost:8080 + - BOOTSTRAP_ADMIN_PASSWORD_PLAIN=RauthyTest12345 + #- HIQLITE=false + #- PG_HOST=db + #- PG_PORT=5432 + #- PG_USER=postgres + #- PG_PASSWORD=postgres + #- PG_DB_NAME=mv_dev + ports: + - "8080:8080" + depends_on: + - mailcrab + - db + networks: + - rauthy-dev + - local + volumes: + - type: volume + source: rauthy-data + target: /app/data volumes: - postgres-data: \ No newline at end of file + postgres-data: + rauthy-data: + + diff --git a/lib/accounts/accounts.ex b/lib/accounts/accounts.ex new file mode 100644 index 0000000..55e8a4b --- /dev/null +++ b/lib/accounts/accounts.ex @@ -0,0 +1,18 @@ +defmodule Mv.Accounts do + @moduledoc """ + AshAuthentication specific domain to handle Authentication for users. + """ + use Ash.Domain, + extensions: [AshPhoenix] + + resources do + resource Mv.Accounts.User do + define :create_user, action: :create + define :list_users, action: :read + define :update_user, action: :update + define :destroy_user, action: :destroy + end + + resource Mv.Accounts.Token + end +end diff --git a/lib/accounts/token.ex b/lib/accounts/token.ex new file mode 100644 index 0000000..ab9c3a7 --- /dev/null +++ b/lib/accounts/token.ex @@ -0,0 +1,14 @@ +defmodule Mv.Accounts.Token do + @moduledoc """ + AshAuthentication specific ressource + """ + use Ash.Resource, + data_layer: AshPostgres.DataLayer, + extensions: [AshAuthentication.TokenResource], + domain: Mv.Accounts + + postgres do + table "tokens" + repo Mv.Repo + end +end diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex new file mode 100644 index 0000000..0de4a38 --- /dev/null +++ b/lib/accounts/user.ex @@ -0,0 +1,127 @@ +defmodule Mv.Accounts.User do + @moduledoc """ + The ressource for keeping user-specific data related to the login process. It is used by AshAuthentication to handle the Authentication strategies like SSO. + """ + use Ash.Resource, + domain: Mv.Accounts, + data_layer: AshPostgres.DataLayer, + extensions: [AshAuthentication] + + # authorizers: [Ash.Policy.Authorizer] + + postgres do + table "users" + repo Mv.Repo + end + + @doc """ + AshAuthentication specific: Defines the strategies we want to use for authentication. + Currently password and SSO with Rauthy as OIDC provider + """ + authentication do + session_identifier Application.compile_env(:mv, :session_identifier, :jti) + + tokens do + enabled? true + token_resource Mv.Accounts.Token + + require_token_presence_for_authentication? Application.compile_env( + :mv, + :require_token_presence_for_authentication, + false + ) + + store_all_tokens? true + + # signing_algorithm "EdDSA" -> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87 + + signing_secret fn _, _ -> + {:ok, Application.get_env(:mv, :token_signing_secret)} + end + end + + strategies do + oidc :rauthy do + client_id Mv.Secrets + base_url Mv.Secrets + redirect_uri Mv.Secrets + client_secret Mv.Secrets + auth_method :client_secret_jwt + code_verifier true + + # id_token_signed_response_alg "EdDSA" #-> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87 + end + + password :password do + identity_field :email + hash_provider AshAuthentication.BcryptProvider + confirmation_required? false + end + end + end + + actions do + defaults [:read, :create, :destroy, :update] + + read :get_by_subject do + description "Get a user by the subject claim in a JWT" + argument :subject, :string, allow_nil?: false + get? true + prepare AshAuthentication.Preparations.FilterBySubject + end + + read :sign_in_with_rauthy do + argument :user_info, :map, allow_nil?: false + argument :oauth_tokens, :map, allow_nil?: false + prepare AshAuthentication.Strategy.OAuth2.SignInPreparation + + filter expr(email == get_path(^arg(:user_info), [:email])) + end + + create :register_with_rauthy do + argument :user_info, :map, allow_nil?: false + argument :oauth_tokens, :map, allow_nil?: false + upsert? true + upsert_identity :unique_email + + change AshAuthentication.GenerateTokenChange + + change fn changeset, _ctx -> + user_info = Ash.Changeset.get_argument(changeset, :user_info) + + changeset + |> Ash.Changeset.change_attribute(:email, user_info["preferred_username"]) + |> Ash.Changeset.change_attribute(:oidc_id, user_info["id"]) + end + end + end + + attributes do + uuid_primary_key :id + + attribute :email, :ci_string, allow_nil?: false, public?: true + attribute :hashed_password, :string, sensitive?: true, allow_nil?: true + attribute :oidc_id, :string, allow_nil?: true + end + + relationships do + belongs_to :member, Mv.Membership.Member + end + + identities do + identity :unique_email, [:email] + identity :unique_oidc_id, [:oidc_id] + end + + # You can customize this if you wish, but this is a safe default that + # only allows user data to be interacted with via AshAuthentication. + # policies do + # bypass AshAuthentication.Checks.AshAuthenticationInteraction do + # authorize_if(always()) + # end + + # policy always() do + # forbid_if(always()) + # end + # end +end diff --git a/lib/accounts/user_identity.exs b/lib/accounts/user_identity.exs new file mode 100644 index 0000000..fd8d2c9 --- /dev/null +++ b/lib/accounts/user_identity.exs @@ -0,0 +1,18 @@ +defmodule Mv.Accounts.UserIdentity do + @moduledoc """ + AshAuthentication specific ressource + """ + use Ash.Resource, + data_layer: AshPostgres.DataLayer, + extensions: [AshAuthentication.UserIdentity], + domain: Mv.Accounts + + postgres do + table "user_identities" + repo Mv.Repo + end + + user_identity do + user_resource Mv.Accounts.User + end +end diff --git a/lib/membership/email.ex b/lib/membership/email.ex new file mode 100644 index 0000000..c611742 --- /dev/null +++ b/lib/membership/email.ex @@ -0,0 +1,37 @@ +defmodule Mv.Membership.Email do + @match_pattern ~S/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/ + @match_regex Regex.compile!(@match_pattern) + @min_length 5 + @max_length 254 + + use Ash.Type.NewType, + subtype_of: :string, + constraints: [ + match: @match_pattern, + trim?: true, + min_length: @min_length, + max_length: @max_length + ] + + @impl true + def cast_input(value, _) when is_binary(value) do + value = String.trim(value) + + cond do + String.length(value) < @min_length -> + :error + + String.length(value) > @max_length -> + :error + + !Regex.match?(@match_regex, value) -> + :error + + true -> + {:ok, value} + end + end + + @impl true + def cast_input(_, _), do: :error +end diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 0538f45..583f173 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -14,6 +14,23 @@ defmodule Mv.Membership.Member do create :create_member do primary? true argument :properties, {:array, :map} + + accept [ + :first_name, + :last_name, + :email, + :birth_date, + :paid, + :phone_number, + :join_date, + :exit_date, + :notes, + :city, + :street, + :house_number, + :postal_code + ] + change manage_relationship(:properties, type: :create) end @@ -21,12 +38,134 @@ defmodule Mv.Membership.Member do primary? true require_atomic? false argument :properties, {:array, :map} + + accept [ + :first_name, + :last_name, + :email, + :birth_date, + :paid, + :phone_number, + :join_date, + :exit_date, + :notes, + :city, + :street, + :house_number, + :postal_code + ] + change manage_relationship(:properties, on_match: :update, on_no_match: :create) end end + validations do + # Required fields are covered by allow_nil? false + + # First name and last name must not be empty + validate present(:first_name) + validate present(:last_name) + validate present(:email) + + # Birth date not in the future + validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0), + where: [present(:birth_date)], + message: "cannot be in the future" + + # Join date not in the future + validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0), + where: [present(:join_date)], + message: "cannot be in the future" + + # Exit date not before join date + validate compare(:exit_date, greater_than: :join_date), + where: [present([:join_date, :exit_date])], + message: "cannot be before join date" + + # Phone number format (only if set) + validate match(:phone_number, ~r/^\+?[0-9\- ]{6,20}$/), + where: [present(:phone_number)], + message: "is not a valid phone number" + + # Postal code format (only if set) + validate match(:postal_code, ~r/^\d{5}$/), + where: [present(:postal_code)], + message: "must consist of 5 digits" + + # Email validation with EctoCommons.EmailValidator + validate fn changeset, _ -> + email = Ash.Changeset.get_attribute(changeset, :email) + + changeset2 = + {%{}, %{email: :string}} + |> Ecto.Changeset.cast(%{email: email}, [:email]) + |> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow]) + + if changeset2.valid? do + :ok + else + {:error, field: :email, message: "is not a valid email"} + end + end + end + attributes do uuid_v7_primary_key :id + + attribute :first_name, :string do + allow_nil? false + constraints min_length: 1 + end + + attribute :last_name, :string do + allow_nil? false + constraints min_length: 1 + end + + attribute :email, :string do + allow_nil? false + constraints min_length: 5, max_length: 254 + end + + attribute :birth_date, :date do + allow_nil? true + end + + attribute :paid, :boolean do + allow_nil? true + end + + attribute :phone_number, :string do + allow_nil? true + end + + attribute :join_date, :date do + allow_nil? true + end + + attribute :exit_date, :date do + allow_nil? true + end + + attribute :notes, :string do + allow_nil? true + end + + attribute :city, :string do + allow_nil? true + end + + attribute :street, :string do + allow_nil? true + end + + attribute :house_number, :string do + allow_nil? true + end + + attribute :postal_code, :string do + allow_nil? true + end end relationships do diff --git a/lib/membership/property.ex b/lib/membership/property.ex index 433fc63..2c432a8 100644 --- a/lib/membership/property.ex +++ b/lib/membership/property.ex @@ -6,6 +6,10 @@ defmodule Mv.Membership.Property do postgres do table "properties" repo Mv.Repo + + references do + reference :member, on_delete: :delete + end end actions do @@ -16,8 +20,17 @@ defmodule Mv.Membership.Property do attributes do uuid_primary_key :id - attribute :value, :string, - description: "Speichert den Wert, Typ-Interpretation per property_type.typ" + attribute :value, :union, + constraints: [ + storage: :type_and_value, + types: [ + boolean: [type: :boolean], + date: [type: :date], + integer: [type: :integer], + string: [type: :string], + email: [type: Mv.Membership.Email] + ] + ] end relationships do @@ -25,4 +38,8 @@ defmodule Mv.Membership.Property do belongs_to :property_type, Mv.Membership.PropertyType end + + calculations do + calculate :value_to_string, :string, expr(value[:value] <> "") + end end diff --git a/lib/membership/property_type.ex b/lib/membership/property_type.ex index 560f679..7444c13 100644 --- a/lib/membership/property_type.ex +++ b/lib/membership/property_type.ex @@ -10,7 +10,7 @@ defmodule Mv.Membership.PropertyType do actions do defaults [:create, :read, :update, :destroy] - default_accept [:name, :type, :description, :immutable, :required] + default_accept [:name, :value_type, :description, :immutable, :required] end attributes do @@ -18,9 +18,10 @@ defmodule Mv.Membership.PropertyType do attribute :name, :string, allow_nil?: false, public?: true - attribute :type, :string, + attribute :value_type, :atom, + constraints: [one_of: [:string, :integer, :boolean, :date, :email]], allow_nil?: false, - description: "Definies the datatype `Property.value` is interpreted as" + description: "Defines the datatype `Property.value` is interpreted as" attribute :description, :string, allow_nil?: true, public?: true diff --git a/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex b/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex new file mode 100644 index 0000000..9e34f29 --- /dev/null +++ b/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex @@ -0,0 +1,32 @@ +defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do + @moduledoc """ + Sends an email for a new user to confirm their email address. + """ + + use AshAuthentication.Sender + use MvWeb, :verified_routes + + import Swoosh.Email + + alias Mv.Mailer + + @impl true + def send(user, token, _) do + new() + # Replace with email from env + |> from({"noreply", "noreply@example.com"}) + |> to(to_string(user.email)) + |> subject("Confirm your email address") + |> html_body(body(token: token)) + |> Mailer.deliver!() + end + + defp body(params) do + url = url(~p"/confirm_new_user/#{params[:token]}") + + """ +

Click this link to confirm your email:

+

#{url}

+ """ + end +end diff --git a/lib/mv/accounts/user/senders/send_password_reset_email.ex b/lib/mv/accounts/user/senders/send_password_reset_email.ex new file mode 100644 index 0000000..7c33d2e --- /dev/null +++ b/lib/mv/accounts/user/senders/send_password_reset_email.ex @@ -0,0 +1,32 @@ +defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do + @moduledoc """ + Sends a password reset email + """ + + use AshAuthentication.Sender + use MvWeb, :verified_routes + + import Swoosh.Email + + alias Mv.Mailer + + @impl true + def send(user, token, _) do + new() + # Replace with email from env + |> from({"noreply", "noreply@example.com"}) + |> to(to_string(user.email)) + |> subject("Reset your password") + |> html_body(body(token: token)) + |> Mailer.deliver!() + end + + defp body(params) do + url = url(~p"/password-reset/#{params[:token]}") + + """ +

Click this link to reset your password:

+

#{url}

+ """ + end +end diff --git a/lib/mv/application.ex b/lib/mv/application.ex index 2a6eaa3..e0bf462 100644 --- a/lib/mv/application.ex +++ b/lib/mv/application.ex @@ -14,6 +14,7 @@ defmodule Mv.Application do {Phoenix.PubSub, name: Mv.PubSub}, # Start the Finch HTTP client for sending emails {Finch, name: Mv.Finch}, + {AshAuthentication.Supervisor, otp_app: :my}, # Start a worker by calling: Mv.Worker.start_link(arg) # {Mv.Worker, arg}, # Start to serve requests, typically the last entry diff --git a/lib/mv/repo.ex b/lib/mv/repo.ex index 490750e..a8d696a 100644 --- a/lib/mv/repo.ex +++ b/lib/mv/repo.ex @@ -5,7 +5,7 @@ defmodule Mv.Repo do @impl true def installed_extensions do # Add extensions here, and the migration generator will install them. - ["ash-functions"] + ["ash-functions", "citext"] end # Don't open unnecessary transactions diff --git a/lib/mv/secrets.ex b/lib/mv/secrets.ex new file mode 100644 index 0000000..6a88eee --- /dev/null +++ b/lib/mv/secrets.ex @@ -0,0 +1,46 @@ +defmodule Mv.Secrets do + use AshAuthentication.Secret + + def secret_for( + [:authentication, :strategies, :rauthy, :client_id], + Mv.Accounts.User, + _opts, + _meth + ) do + get_config(:client_id) + end + + def secret_for( + [:authentication, :strategies, :rauthy, :redirect_uri], + Mv.Accounts.User, + _opts, + _meth + ) do + get_config(:redirect_uri) + end + + def secret_for( + [:authentication, :strategies, :rauthy, :client_secret], + Mv.Accounts.User, + _opts, + _meth + ) do + get_config(:client_secret) + end + + def secret_for( + [:authentication, :strategies, :rauthy, :base_url], + Mv.Accounts.User, + _opts, + _meth + ) do + get_config(:base_url) + end + + defp get_config(key) do + :mv + |> Application.fetch_env!(:rauthy) + |> Keyword.fetch!(key) + |> then(&{:ok, &1}) + end +end diff --git a/lib/mv_web.ex b/lib/mv_web.ex index de7184d..4254449 100644 --- a/lib/mv_web.ex +++ b/lib/mv_web.ex @@ -55,6 +55,7 @@ defmodule MvWeb do use Phoenix.LiveView, layout: {MvWeb.Layouts, :app} + on_mount MvWeb.LiveHelpers unquote(html_helpers()) end end diff --git a/lib/mv_web/auth_overrides.ex b/lib/mv_web/auth_overrides.ex new file mode 100644 index 0000000..bec3354 --- /dev/null +++ b/lib/mv_web/auth_overrides.ex @@ -0,0 +1,20 @@ +defmodule MvWeb.AuthOverrides do + use AshAuthentication.Phoenix.Overrides + + # configure your UI overrides here + + # First argument to `override` is the component name you are overriding. + # The body contains any number of configurations you wish to override + # Below are some examples + + # For a complete reference, see https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html + + # override AshAuthentication.Phoenix.Components.Banner do + # set :image_url, "https://media.giphy.com/media/g7GKcSzwQfugw/giphy.gif" + # set :text_class, "bg-red-500" + # end + + # override AshAuthentication.Phoenix.Components.SignIn do + # set :show_banner, false + # end +end diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 1e9b835..c35f1ce 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -673,4 +673,28 @@ defmodule MvWeb.CoreComponents do def translate_errors(errors, field) when is_list(errors) do for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) end + + @doc """ + Renders a list of items with name and value pairs. + + ## Examples + <.generic_list items={[ + {item.name, item.value}, + {other.name, other.value} + ]} /> + """ + attr :items, :list, required: true, doc: "List of {name, value} tuples" + + def generic_list(assigns) do + ~H""" +
+
+
+
{name}
+
{value}
+
+
+
+ """ + end end diff --git a/lib/mv_web/components/layouts/app.html.heex b/lib/mv_web/components/layouts/app.html.heex index 3b3b607..54258db 100644 --- a/lib/mv_web/components/layouts/app.html.heex +++ b/lib/mv_web/components/layouts/app.html.heex @@ -9,6 +9,13 @@

+
+ + +
@elixirphoenix diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex new file mode 100644 index 0000000..a8375d1 --- /dev/null +++ b/lib/mv_web/controllers/auth_controller.ex @@ -0,0 +1,59 @@ +require Logger + +defmodule MvWeb.AuthController do + use MvWeb, :controller + use AshAuthentication.Phoenix.Controller + + def success(conn, activity, user, _token) do + return_to = get_session(conn, :return_to) || ~p"/" + + message = + case activity do + {:confirm_new_user, :confirm} -> gettext("Your email address has now been confirmed") + {:password, :reset} -> gettext("Your password has successfully been reset") + _ -> gettext("You are now signed in") + end + + conn + |> delete_session(:return_to) + |> store_in_session(user) + # If your resource has a different name, update the assign name here (i.e :current_admin) + |> assign(:current_user, user) + |> put_flash(:info, message) + |> redirect(to: return_to) + end + + def failure(conn, activity, reason) do + Logger.error(%{conn: conn, reason: reason}) + + message = + case {activity, reason} do + {_, + %AshAuthentication.Errors.AuthenticationFailed{ + caused_by: %Ash.Error.Forbidden{ + errors: [%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}] + } + }} -> + gettext(""" + You have already signed in another way, but have not confirmed your account. + You can confirm your account using the link we sent to you, or by resetting your password. + """) + + _ -> + gettext("Incorrect email or password") + end + + conn + |> put_flash(:error, message) + |> redirect(to: ~p"/sign-in") + end + + def sign_out(conn, _params) do + return_to = get_session(conn, :return_to) || ~p"/" + + conn + |> clear_session(:mv) + |> put_flash(:info, gettext("You are now signed out")) + |> redirect(to: return_to) + end +end diff --git a/lib/mv_web/controllers/page_html/home.html.heex b/lib/mv_web/controllers/page_html/home.html.heex index d72b03c..f13765e 100644 --- a/lib/mv_web/controllers/page_html/home.html.heex +++ b/lib/mv_web/controllers/page_html/home.html.heex @@ -1,222 +1,55 @@ -<.flash_group flash={@flash} /> - -
-
- -

- Phoenix Framework - - v{Application.spec(:phoenix, :vsn)} - -

-

- Peace of mind from prototype to production. -

-

- Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. -

-
-
- -
- - - - -
- - - Deploy your application - + + + +
+
+
+

+ Demo +

+
+
+
+
+
+
+
+
+
diff --git a/lib/mv_web/endpoint.ex b/lib/mv_web/endpoint.ex index 47bcb23..090e54c 100644 --- a/lib/mv_web/endpoint.ex +++ b/lib/mv_web/endpoint.ex @@ -25,6 +25,10 @@ defmodule MvWeb.Endpoint do gzip: false, only: MvWeb.static_paths() + if Code.ensure_loaded?(Tidewave) do + plug Tidewave + end + # Code reloading can be explicitly enabled under the # :code_reloader configuration of your endpoint. if code_reloading? do diff --git a/lib/mv_web/live_helpers.ex b/lib/mv_web/live_helpers.ex new file mode 100644 index 0000000..03d7d45 --- /dev/null +++ b/lib/mv_web/live_helpers.ex @@ -0,0 +1,7 @@ +defmodule MvWeb.LiveHelpers do + def on_mount(:default, _params, session, socket) do + locale = session["locale"] || "en" + Gettext.put_locale(locale) + {:cont, socket} + end +end diff --git a/lib/mv_web/live_user_auth.ex b/lib/mv_web/live_user_auth.ex new file mode 100644 index 0000000..67bef70 --- /dev/null +++ b/lib/mv_web/live_user_auth.ex @@ -0,0 +1,46 @@ +defmodule MvWeb.LiveUserAuth do + @moduledoc """ + Helpers for authenticating users in LiveViews. + """ + + import Phoenix.Component + use MvWeb, :verified_routes + + # This is used for nested liveviews to fetch the current user. + # To use, place the following at the top of that liveview: + # on_mount {MvWeb.LiveUserAuth, :current_user} + def on_mount(:current_user, _params, session, socket) do + return_to = session[:return_to] + + socket = + socket + |> assign(:return_to, return_to) + |> AshAuthentication.Phoenix.LiveSession.assign_new_resources(session) + + {:cont, session, socket} + end + + def on_mount(:live_user_optional, _params, _session, socket) do + if socket.assigns[:current_user] do + {:cont, socket} + else + {:cont, assign(socket, :current_user, nil)} + end + end + + def on_mount(:live_user_required, _params, _session, socket) do + if socket.assigns[:current_user] do + {:cont, socket} + else + {:halt, Phoenix.LiveView.redirect(socket, to: ~p"/sign-in")} + end + end + + def on_mount(:live_no_user, _params, _session, socket) do + if socket.assigns[:current_user] do + {:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")} + else + {:cont, assign(socket, :current_user, nil)} + end + end +end diff --git a/lib/mv_web/locale_controller.ex b/lib/mv_web/locale_controller.ex new file mode 100644 index 0000000..3c8056f --- /dev/null +++ b/lib/mv_web/locale_controller.ex @@ -0,0 +1,18 @@ +defmodule MvWeb.LocaleController do + use MvWeb, :controller + + def set_locale(conn, %{"locale" => locale}) do + conn + |> put_session(:locale, locale) + |> redirect(to: get_referer(conn) || "/") + end + + defp get_referer(conn) do + conn.req_headers + |> Enum.find(fn {k, _v} -> k == "referer" end) + |> case do + {_, v} -> URI.parse(v).path + _ -> nil + end + end +end diff --git a/lib/mv_web/member_live/form_component.ex b/lib/mv_web/member_live/form_component.ex index b91d3c0..5535d1a 100644 --- a/lib/mv_web/member_live/form_component.ex +++ b/lib/mv_web/member_live/form_component.ex @@ -9,7 +9,11 @@ defmodule MvWeb.MemberLive.FormComponent do Enum.map(property_types, fn pt -> %{ "property_type_id" => pt.id, - "value" => nil + "value" => %{ + "type" => pt.value_type, + "value" => nil, + "_union_type" => Atom.to_string(pt.value_type) + } } end) @@ -22,7 +26,9 @@ defmodule MvWeb.MemberLive.FormComponent do
<.header> {@title} - <:subtitle>Use this form to manage member records and their properties. + <:subtitle> + {gettext("Use this form to manage member records and their properties.")} + <.simple_form @@ -32,9 +38,32 @@ defmodule MvWeb.MemberLive.FormComponent do phx-change="validate" phx-submit="save" > + <.input field={@form[:first_name]} label={gettext("First Name")} required /> + <.input field={@form[:last_name]} label={gettext("Last Name")} required /> + <.input field={@form[:email]} label={gettext("Email")} required type="email" /> + <.input field={@form[:birth_date]} label={gettext("Birth Date")} type="date" /> + <.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" /> + <.input field={@form[:phone_number]} label={gettext("Phone Number")} /> + <.input field={@form[:join_date]} label={gettext("Join Date")} type="date" /> + <.input field={@form[:exit_date]} label={gettext("Exit Date")} type="date" /> + <.input field={@form[:notes]} label={gettext("Notes")} /> + <.input field={@form[:city]} label={gettext("City")} /> + <.input field={@form[:street]} label={gettext("Street")} /> + <.input field={@form[:house_number]} label={gettext("House Number")} /> + <.input field={@form[:postal_code]} label={gettext("Postal Code")} /> + +

{gettext("Custom Properties")}

<.inputs_for :let={f_property} field={@form[:properties]}> <% type = Enum.find(@property_types, &(&1.id == f_property[:property_type_id].value)) %> - <.input field={f_property[:value]} label={type && type.name} /> + <.inputs_for :let={value_form} field={f_property[:value]}> + <% input_type = + cond do + type && type.value_type == :boolean -> "checkbox" + type && type.value_type == :date -> :date + true -> :text + end %> + <.input field={value_form[:value]} label={type && type.name} type={input_type} /> + <:actions> - <.button phx-disable-with="Saving...">Save Member + <.button phx-disable-with={gettext("Saving...")}>{gettext("Save Member")}
@@ -68,9 +97,16 @@ defmodule MvWeb.MemberLive.FormComponent do {:ok, member} -> notify_parent({:saved, member}) + action = + case socket.assigns.form.source.type do + :create -> gettext("create") + :update -> gettext("update") + other -> to_string(other) + end + socket = socket - |> put_flash(:info, "Member #{socket.assigns.form.source.type}d successfully") + |> put_flash(:info, gettext("Member %{action} successfully", action: action)) |> push_patch(to: socket.assigns.patch) {:noreply, socket} @@ -95,12 +131,27 @@ defmodule MvWeb.MemberLive.FormComponent do not Enum.member?(existing_properties, Map.get(i, "property_type_id")) end + params = %{ + "properties" => + Enum.map(member.properties, fn prop -> + %{ + "property_type_id" => prop.property_type_id, + "value" => %{ + "_union_type" => Atom.to_string(prop.value.type), + "type" => prop.value.type, + "value" => prop.value.value + } + } + end) + } + form = AshPhoenix.Form.for_update( member, :update_member, api: Mv.Membership, as: "member", + params: params, forms: [auto?: true] ) diff --git a/lib/mv_web/member_live/index.ex b/lib/mv_web/member_live/index.ex index 4e37429..452ebab 100644 --- a/lib/mv_web/member_live/index.ex +++ b/lib/mv_web/member_live/index.ex @@ -5,10 +5,10 @@ defmodule MvWeb.MemberLive.Index do def render(assigns) do ~H""" <.header> - Listing Members + {gettext("Listing Members")} <:actions> <.link patch={~p"/members/new"}> - <.button>New Member + <.button>{gettext("New Member")} @@ -18,22 +18,27 @@ defmodule MvWeb.MemberLive.Index do rows={@streams.members} row_click={fn {_id, member} -> JS.navigate(~p"/members/#{member}") end} > - <:col :let={{_id, member}} label="Id">{member.id} + + <:col :let={{_id, member}} label={gettext("First Name")}>{member.first_name} + <:col :let={{_id, member}} label={gettext("Last Name")}>{member.last_name} + <:col :let={{_id, member}} label={gettext("Email")}>{member.email} + <:col :let={{_id, member}} label={gettext("City")}>{member.city} + <:col :let={{_id, member}} label={gettext("Join Date")}>{member.join_date} <:action :let={{_id, member}}>
- <.link navigate={~p"/members/#{member}"}>Show + <.link navigate={~p"/members/#{member}"}>{gettext("Show")}
- <.link patch={~p"/members/#{member}/edit"}>Edit + <.link patch={~p"/members/#{member}/edit"}>{gettext("Edit")} <:action :let={{id, member}}> <.link phx-click={JS.push("delete", value: %{id: member.id}) |> hide("##{id}")} - data-confirm="Are you sure?" + data-confirm={gettext("Are you sure?")} > - Delete + {gettext("Delete")} @@ -68,19 +73,19 @@ defmodule MvWeb.MemberLive.Index do defp apply_action(socket, :edit, %{"id" => id}) do socket - |> assign(:page_title, "Edit Member") + |> assign(:page_title, gettext("Edit Member")) |> assign(:member, Ash.get!(Mv.Membership.Member, id)) end defp apply_action(socket, :new, _params) do socket - |> assign(:page_title, "New Member") + |> assign(:page_title, gettext("New Member")) |> assign(:member, nil) end defp apply_action(socket, :index, _params) do socket - |> assign(:page_title, "Listing Members") + |> assign(:page_title, gettext("Listing Members")) |> assign(:member, nil) end diff --git a/lib/mv_web/member_live/show.ex b/lib/mv_web/member_live/show.ex index 47e0f92..612abd6 100644 --- a/lib/mv_web/member_live/show.ex +++ b/lib/mv_web/member_live/show.ex @@ -1,25 +1,55 @@ defmodule MvWeb.MemberLive.Show do use MvWeb, :live_view + import Ash.Query @impl true def render(assigns) do ~H""" <.header> - Member {@member.id} - <:subtitle>This is a member record from your database. + {@member.first_name} {@member.last_name} + <:subtitle>{gettext("This is a member record from your database.")} <:actions> <.link patch={~p"/members/#{@member}/show/edit"} phx-click={JS.push_focus()}> - <.button>Edit member + <.button>{gettext("Edit member")} <.list> - <:item title="Id">{@member.id} + <:item title={gettext("Id")}>{@member.id} + <:item title={gettext("First Name")}>{@member.first_name} + <:item title={gettext("Last Name")}>{@member.last_name} + <:item title={gettext("Email")}>{@member.email} + <:item title={gettext("Birth Date")}>{@member.birth_date} + <:item title={gettext("Paid")}> + {if @member.paid, do: gettext("Yes"), else: gettext("No")} + + <:item title={gettext("Phone Number")}>{@member.phone_number} + <:item title={gettext("Join Date")}>{@member.join_date} + <:item title={gettext("Exit Date")}>{@member.exit_date} + <:item title={gettext("Notes")}>{@member.notes} + <:item title={gettext("City")}>{@member.city} + <:item title={gettext("Street")}>{@member.street} + <:item title={gettext("House Number")}>{@member.house_number} + <:item title={gettext("Postal Code")}>{@member.postal_code} - <.back navigate={~p"/members"}>Back to members +

{gettext("Custom Properties")}

+ <.generic_list items={ + Enum.map(@member.properties, fn p -> + { + # name + p.property_type && p.property_type.name, + # value + case p.value do + %{value: v} -> v + v -> v + end + } + end) + } /> + <.back navigate={~p"/members"}>{gettext("Back to members")} <.modal :if={@live_action == :edit} @@ -46,12 +76,19 @@ defmodule MvWeb.MemberLive.Show do @impl true def handle_params(%{"id" => id}, _, socket) do + query = + Mv.Membership.Member + |> filter(id == ^id) + |> load(properties: [:property_type]) + + member = Ash.read_one!(query) + {:noreply, socket |> assign(:page_title, page_title(socket.assigns.live_action)) - |> assign(:member, Ash.get!(Mv.Membership.Member, id))} + |> assign(:member, member)} end - defp page_title(:show), do: "Show Member" - defp page_title(:edit), do: "Edit Member" + defp page_title(:show), do: gettext("Show Member") + defp page_title(:edit), do: gettext("Edit Member") end diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index 3e9bdca..595a2da 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -1,6 +1,10 @@ defmodule MvWeb.Router do use MvWeb, :router + use AshAuthentication.Phoenix.Router + + import AshAuthentication.Plug.Helpers + pipeline :browser do plug :accepts, ["html"] plug :fetch_session @@ -8,33 +12,92 @@ defmodule MvWeb.Router do plug :put_root_layout, html: {MvWeb.Layouts, :root} plug :protect_from_forgery plug :put_secure_browser_headers + plug :load_from_session + plug :set_locale end pipeline :api do plug :accepts, ["json"] + plug :load_from_bearer + plug :set_actor, :user end scope "/", MvWeb do pipe_through :browser - get "/", PageController, :home - live "/members", MemberLive.Index, :index - live "/members/new", MemberLive.Index, :new - live "/members/:id/edit", MemberLive.Index, :edit - live "/members/:id", MemberLive.Show, :show - live "/members/:id/show/edit", MemberLive.Show, :edit + ash_authentication_live_session :authenticated_routes do + # in each liveview, add one of the following at the top of the module: + # + # If an authenticated user must be present: + # on_mount {MvWeb.LiveUserAuth, :live_user_required} + # + # If an authenticated user *may* be present: + # on_mount {MvWeb.LiveUserAuth, :live_user_optional} + # + # If an authenticated user must *not* be present: + # on_mount {MvWeb.LiveUserAuth, :live_no_user} + end + end - live "/property_types", PropertyTypeLive.Index, :index - live "/property_types/new", PropertyTypeLive.Index, :new - live "/property_types/:id/edit", PropertyTypeLive.Index, :edit - live "/property_types/:id", PropertyTypeLive.Show, :show - live "/property_types/:id/show/edit", PropertyTypeLive.Show, :edit + scope "/", MvWeb do + pipe_through :browser - live "/properties", PropertyLive.Index, :index - live "/properties/new", PropertyLive.Index, :new - live "/properties/:id/edit", PropertyLive.Index, :edit - live "/properties/:id", PropertyLive.Show, :show - live "/properties/:id/show/edit", PropertyLive.Show, :edit + @doc """ + AshAuthentication-specific: We define that all routes can only be accessed when the user is signed in. + """ + ash_authentication_live_session :authentication_required, + on_mount: {MvWeb.LiveUserAuth, :live_user_required} do + get "/", PageController, :home + + live "/members", MemberLive.Index, :index + live "/members/new", MemberLive.Index, :new + live "/members/:id/edit", MemberLive.Index, :edit + live "/members/:id", MemberLive.Show, :show + live "/members/:id/show/edit", MemberLive.Show, :edit + + live "/property_types", PropertyTypeLive.Index, :index + live "/property_types/new", PropertyTypeLive.Index, :new + live "/property_types/:id/edit", PropertyTypeLive.Index, :edit + live "/property_types/:id", PropertyTypeLive.Show, :show + live "/property_types/:id/show/edit", PropertyTypeLive.Show, :edit + + live "/properties", PropertyLive.Index, :index + live "/properties/new", PropertyLive.Index, :new + live "/properties/:id/edit", PropertyLive.Index, :edit + live "/properties/:id", PropertyLive.Show, :show + live "/properties/:id/show/edit", PropertyLive.Show, :edit + + post "/set_locale", LocaleController, :set_locale + end + + # ASHAUTHENTICATION GENERATED AUTH ROUTES + auth_routes AuthController, Mv.Accounts.User, path: "/auth" + sign_out_route AuthController + + # Remove these if you'd like to use your own authentication views + sign_in_route register_path: "/register", + reset_path: "/reset", + auth_routes_prefix: "/auth", + on_mount: [{MvWeb.LiveUserAuth, :live_no_user}], + overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default], + gettext_backend: {MvWeb.Gettext, "default"} + + # Remove this if you do not want to use the reset password feature + reset_route auth_routes_prefix: "/auth", + overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default], + gettext_backend: {MvWeb.Gettext, "default"} + + # Remove this if you do not use the confirmation strategy + confirm_route Mv.Accounts.User, :confirm_new_user, + auth_routes_prefix: "/auth", + overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default], + gettext_backend: {MvWeb.Gettext, "default"} + + # Remove this if you do not use the magic link strategy. + # magic_sign_in_route(Mv.Accounts.User, :magic_link, + # auth_routes_prefix: "/auth", + # overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default] + # ) end # Other scopes may use custom stacks. @@ -68,4 +131,10 @@ defmodule MvWeb.Router do ash_admin "/" end end + + defp set_locale(conn, _opts) do + locale = get_session(conn, :locale) || "en" + Gettext.put_locale(MvWeb.Gettext, locale) + conn + end end diff --git a/mix.exs b/mix.exs index 687a111..2284006 100644 --- a/mix.exs +++ b/mix.exs @@ -33,13 +33,17 @@ defmodule Mv.MixProject do # Type `mix help deps` for examples and options. defp deps do [ + {:tidewave, "~> 0.2", only: [:dev]}, {:sourceror, "~> 1.8", only: [:dev, :test]}, - {:live_debugger, "~> 0.1", only: [:dev]}, + {:live_debugger, "~> 0.3", only: [:dev]}, {:ash_admin, "~> 0.13"}, {:ash_postgres, "~> 2.0"}, {:ash_phoenix, "~> 2.0"}, {:ash, "~> 3.0"}, - {:igniter, "~> 0.5", only: [:dev, :test]}, + {:bcrypt_elixir, "~> 3.0"}, + {:ash_authentication, "~> 4.9"}, + {:ash_authentication_phoenix, "~> 2.10"}, + {:igniter, "~> 0.6", only: [:dev, :test]}, {:phoenix, "~> 1.7.20"}, {:phoenix_ecto, "~> 4.5"}, {:ecto_sql, "~> 3.10"}, @@ -49,26 +53,27 @@ defmodule Mv.MixProject do {:phoenix_live_view, "~> 1.0.0"}, {:floki, ">= 0.30.0", only: :test}, {:phoenix_live_dashboard, "~> 0.8.3"}, - {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, + {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, {:heroicons, github: "tailwindlabs/heroicons", - tag: "v2.1.1", + tag: "v2.2.0", sparse: "optimized", app: false, compile: false, depth: 1}, {:swoosh, "~> 1.5"}, - {:finch, "~> 0.13"}, + {:finch, "~> 0.20"}, {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, {:gettext, "~> 0.26"}, {:jason, "~> 1.2"}, - {:dns_cluster, "~> 0.1.1"}, + {:dns_cluster, "~> 0.2.0"}, {:bandit, "~> 1.5"}, {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, - {:sobelow, "~> 0.13", only: [:dev, :test], runtime: false}, - {:credo, "~> 1.7", only: [:dev, :test], runtime: false} + {:sobelow, "~> 0.14", only: [:dev, :test], runtime: false}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:ecto_commons, "~> 0.3"} ] end @@ -90,7 +95,8 @@ defmodule Mv.MixProject do "tailwind mv --minify", "esbuild mv --minify", "phx.digest" - ] + ], + "phx.routes": ["phx.routes", "ash_authentication.phoenix.routes"] ] end end diff --git a/mix.lock b/mix.lock index b7190ef..6aaec65 100644 --- a/mix.lock +++ b/mix.lock @@ -1,71 +1,87 @@ %{ - "ash": {:hex, :ash, "3.5.6", "2f187150110b4c280c8551ad411f56d95862fcb37c067a0b8b94eb682bcc43e8", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.24 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d9aeb5aacfdc12253fae1e7e4720991868c5f69632c2766afb03b2b1830f55"}, - "ash_admin": {:hex, :ash_admin, "0.13.4", "101bc40e299441a65d5c9e911f3801b6ab23eca2e53bb778ed0c6586993cc453", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "d546e2f0d87a745c2156c65960f7a7a8b89abd238be5bfabac2176e814846415"}, - "ash_phoenix": {:hex, :ash_phoenix, "2.2.0", "aee367f4b3e4c7cfb6a4f1bc219409e0d40961aa9eee5da2113572b66d9f620d", [:mix], [{:ash, ">= 3.4.31 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, ">= 0.4.3 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "ac9d58609b4232094c4789c6bd5f9039d1caa14ca6a893d6ab6ac1aee984e122"}, - "ash_postgres": {:hex, :ash_postgres, "2.5.16", "9fc82621aea3c4777f9a322be8cdce10488f0eed50e7d75465285c131c30ec6b", [:mix], [{:ash, ">= 3.4.69 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.2.68 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, ">= 3.12.1 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "d9f328683ea861707f19e89c16c2c4f3527431c50071b2aea4bac6a822f4f448"}, - "ash_sql": {:hex, :ash_sql, "0.2.71", "40cabdd0c7af2eaa0096b2b0eae886085fed1e3b326e20434274120e11dec2c5", [:mix], [{:ash, "~> 3.5", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "6e22da3d020aecaca9858f430828c12988c3418d252fa39be3f43fde9fd4224d"}, - "bandit": {:hex, :bandit, "1.6.8", "be6fcbe01a74e6cba42ae35f4085acaeae9b2d8d360c0908d0b9addbc2811e47", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4fc08c8d4733735d175a007ecb25895e84d09292b0180a2e9f16948182c88b6e"}, + "ash": {:hex, :ash, "3.5.26", "f2e884623bfc39e0228a4fee0c41fbdb90195c6b1e1618a0a97f03f2dfbb1c4f", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.4 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.2.65 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a9f5edbb7d838e2053e84f62c428650dadbff3ea4ec40ef68f167483eb7e9012"}, + "ash_admin": {:hex, :ash_admin, "0.13.11", "00bf3228b09ed6137e49a68374262f1de2cd5e1ea43ac2a6e2666cce71b7032e", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "ed9e8833affb80454ba04c51a70ad96da95bb9d24429cf4f9d7cd538306c6256"}, + "ash_authentication": {:hex, :ash_authentication, "4.9.6", "c333fa8c2a61a64f70be1c69b8479967b3bce448e6420088821c0634dfdace81", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, ">= 3.4.29 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "~> 0.2.13", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "805ca73cc6723c60f8cb988a7c1b883f0d0db317e77007e52b30508e0cc32674"}, + "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.10.3", "b3c32e51a77eefc02c155eccdd17f1b697da3314fb40102854dcdd79288325b7", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, ">= 4.9.1 and < 5.0.0-0", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "89be0de638123193933a54ae15b9d1c670bb4010775c38b2b22a99180ecc1ac3"}, + "ash_phoenix": {:hex, :ash_phoenix, "2.3.10", "c038cbcd0550a4a26d7ee2d936d2886415dfa69fc5952f45b0e3737c3293a4d3", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "c4b7d86e1636c82c6f6a89983af17f19e55f25b066120193d9d3524d2013456d"}, + "ash_postgres": {:hex, :ash_postgres, "2.6.10", "d39ede943fe26dbd69c7511797adcff7f5e4c85e11990e23ec90b9a52a464bf6", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.2.72 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "bcce5bf8c9913be1eb6c2b0be0709decfc79eac1148c3c37d28eca4fda753ebe"}, + "ash_sql": {:hex, :ash_sql, "0.2.85", "96a35d197f5ff846c17aca9225d4baafa0fda6c804f1e46a79345d237b5e5c5f", [:mix], [{:ash, ">= 3.5.25 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "62e7f8b79bcb04d82654ba519008b80bd21bd177ec646e9fffb87ec34285722b"}, + "assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"}, + "bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"}, + "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "castore": {:hex, :castore, "1.0.12", "053f0e32700cbec356280c0e835df425a3be4bc1e0627b714330ad9d0f05497f", [:mix], [], "hexpm", "3dca286b2186055ba0c9449b4e95b97bf1b57b47c1f2644555879e659960c224"}, + "castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"}, + "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, + "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, - "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, + "db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, - "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, - "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, - "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, - "esbuild": {:hex, :esbuild, "0.9.0", "f043eeaca4932ca8e16e5429aebd90f7766f31ac160a25cbd9befe84f2bc068f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b415027f71d5ab57ef2be844b2a10d0c1b5a492d431727f43937adce22ba45ae"}, + "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, + "ecto": {:hex, :ecto, "3.13.2", "7d0c0863f3fc8d71d17fc3ad3b9424beae13f02712ad84191a826c7169484f01", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669d9291370513ff56e7b7e7081b7af3283d02e046cf3d403053c557894a0b3e"}, + "ecto_commons": {:hex, :ecto_commons, "0.3.6", "7b1d9e59396cf8c8cbe5a26d50d03f9b6d0fe6c640210dd503622f276f1e59bb", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.2", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "3f12981a1e398f206c5d2014e7b732b7ec91b110b9cb84875cb5b28fc75d7a0a"}, + "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, + "ex_phone_number": {:hex, :ex_phone_number, "0.4.5", "2065cc48c3e9d1ed9821f50877c32f2f6898362cb990f44147ca217c5d1374ed", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "67163f8706f8cbfef1b1f4b9230c461f19786d0d79fd0b22cbeeefc6f0b99d4a"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, - "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, - "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, + "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, + "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, - "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, - "hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"}, - "igniter": {:hex, :igniter, "0.5.46", "e3ad5b07a194b6e550ddd303bac45a126a65c6157c8acb664b22011cac8e34fd", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:inflex, "~> 2.0", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "64c59b696b678b2b83e2ee923f5254ac6479aff6c65dd513383bc0e4cdaeeeb7"}, + "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "igniter": {:hex, :igniter, "0.6.19", "d87703b36890bc4278341d966a7ed8e10604a18610a4331ac10c75d1af48fff4", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "c2070b3fdbd238fc0a0bfbc1f125b5c0f79a1fe2f5b3c7b43cd33de696783663"}, "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, + "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, - "live_debugger": {:hex, :live_debugger, "0.1.5", "e7324a186071ac19885945e4ca7f3257ee07ed8c4ac5862305cab1fd595073aa", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "dc9416960bfe12873bc37707d4669797850f4e8ca4fe192f3195330e1c623634"}, - "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, + "live_debugger": {:hex, :live_debugger, "0.3.1", "4b4d36481c3b0a49ec082c8268d37974ece34d2091ac323ccc0c906eb0c0d032", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20.4 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "e50836495134b0dde98dc96919340749130ecb83618ea99d63a3a58ed1dcc27d"}, + "luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, - "mix_audit": {:hex, :mix_audit, "2.1.4", "0a23d5b07350cdd69001c13882a4f5fb9f90fbd4cbf2ebc190a2ee0d187ea3e9", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "fd807653cc8c1cada2911129c7eb9e985e3cc76ebf26f4dd628bb25bbcaa7099"}, + "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "owl": {:hex, :owl, "0.12.2", "65906b525e5c3ef51bab6cba7687152be017aebe1da077bb719a5ee9f7e60762", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "6398efa9e1fea70a04d24231e10dcd66c1ac1aa2da418d20ef5357ec61de2880"}, - "phoenix": {:hex, :phoenix, "1.7.20", "6bababaf27d59f5628f9b608de902a021be2cecefb8231e1dbdc0a2e2e480e9b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "6be2ab98302e8784a31829e0d50d8bdfa81a23cd912c395bafd8b8bfb5a086c2"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, + "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, - "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.6", "7b1f0327f54c9eb69845fd09a77accf922f488c549a7e7b8618775eb603a62c7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "1681ab813ec26ca6915beb3414aa138f298e17721dc6a2bde9e6eb8a62360ff6"}, - "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.5", "f072166f87c44ffaf2b47b65c5ced8c375797830e517bfcf0a006fe7eb113911", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "94abbc84df8a93a64514fc41528695d7326b6f3095e906b32f264ec4280811f3"}, + "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.0", "2791fac0e2776b640192308cc90c0dbcf67843ad51387ed4ecae2038263d708d", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b3a1fa036d7eb2f956774eda7a7638cf5123f8f2175aca6d6420a7f95e598e1c"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.17", "beeb16d83a7d3760f7ad463df94e83b087577665d2acc0bf2987cd7d9778068f", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a4ca05c1eb6922c4d07a508a75bfa12c45e5f4d8f77ae83283465f02c53741e1"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, - "plug": {:hex, :plug, "1.17.0", "a0832e7af4ae0f4819e0c08dd2e7482364937aea6a8a997a679f2cbb7e026b2e", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6692046652a69a00a5a21d0b7e11fcf401064839d59d6b8787f23af55b1e6bc"}, - "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, + "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, - "reactor": {:hex, :reactor, "0.15.2", "8c1b3fe0527b7a92b0b22c3f33f2e66858dd069bf1dd51d1031f63cd8cbd1fd5", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "091435a1fa0cab9bc2ed3934b203a0fd190f62e8b6aca63741f9242b8c7631ac"}, - "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"}, + "reactor": {:hex, :reactor, "0.15.6", "d717f9add549b25a089a94c90197718d2d838e35d81dd776b1d81587d4cf2aaa", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "74db98165e3644d86e0f723672d91ceca4339eaa935bcad7e78bf146a46d77b9"}, + "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, "rewrite": {:hex, :rewrite, "1.1.2", "f5a5d10f5fed1491a6ff48e078d4585882695962ccc9e6c779bae025d1f92eda", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "7f8b94b1e3528d0a47b3e8b7bfeca559d2948a65fa7418a9ad7d7712703d39d4"}, - "sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"}, - "sourceror": {:hex, :sourceror, "1.9.0", "3bf5fe2d017aaabe3866d8a6da097dd7c331e0d2d54e59e21c2b066d47f1e08e", [:mix], [], "hexpm", "d20a9dd5efe162f0d75a307146faa2e17b823ea4f134f662358d70f0332fed82"}, - "spark": {:hex, :spark, "2.2.52", "50094275c9bbafa8e5e9eed0ab61983ee209a500e7044914ccf88e9921ae5082", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "f8de8c298bbbf7abd2a80d0ecabcefef65941f397cdbe94ce6165a121b09084f"}, - "spitfire": {:hex, :spitfire, "0.2.0", "0de1f519a23f65bde40d316adad53c07a9563f25cc68915d639d8a509a0aad8a", [:mix], [], "hexpm", "743daaee2d81a0d8095431729f478ce49b47ea8943c7d770de86704975cb7775"}, + "slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"}, + "sobelow": {:hex, :sobelow, "0.14.0", "dd82aae8f72503f924fe9dd97ffe4ca694d2f17ec463dcfd365987c9752af6ee", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "7ecf91e298acfd9b24f5d761f19e8f6e6ac585b9387fb6301023f1f2cd5eed5f"}, + "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, + "spark": {:hex, :spark, "2.2.67", "67626cb9f59ea4b1c5aa85d4afdd025e0740cbd49ed82665d0a40ff007d7fd4b", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "c8575402e3afc66871362e821bece890536d16319cdb758c5fb2d1250182e46f"}, + "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, "splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"}, "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, - "swoosh": {:hex, :swoosh, "1.18.2", "41279e8449b65d14b571b66afe9ab352c3b0179291af8e5f4ad9207f489ad11a", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "032fcb2179f6d4e3b90030514ddc8d3946d8b046be939d121db480ca78adbc38"}, + "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, + "swoosh": {:hex, :swoosh, "1.19.3", "02ad4455939f502386e4e1443d4de94c514995fd0e51b3cafffd6bd270ffe81c", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "04a10f8496786b744b84130e3510eb53ca51e769c39511b65023bdf4136b732f"}, "tailwind": {:hex, :tailwind, "0.3.1", "a89d2835c580748c7a975ad7dd3f2ea5e63216dc16d44f9df492fbd12c094bed", [:mix], [], "hexpm", "98a45febdf4a87bc26682e1171acdedd6317d0919953c353fcd1b4f9f4b676a2"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, - "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, - "thousand_island": {:hex, :thousand_island, "1.3.11", "b68f3e91f74d564ae20b70d981bbf7097dde084343c14ae8a33e5b5fbb3d6f37", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "555c18c62027f45d9c80df389c3d01d86ba11014652c00be26e33b1b64e98d29"}, + "thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"}, + "tidewave": {:hex, :tidewave, "0.2.0", "e98378803e535d3035138e4b354dcfca26b7f862fd44cffef5aa697b814c0b0b", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.47 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "6ad11829f4600cd69955ffc66935e6456b775fea095172147244ba6f65986735"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.11.0", "9e9ccd134e861c66b84825a3542a1c22ba33f338d82c07282f4f1f52d847bd50", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "53cc28357ee7eb952344995787f4bb8cc3cecbf189652236e9b163e8ce1bc242"}, - "ymlr": {:hex, :ymlr, "5.1.3", "a8061add5a378e20272a31905be70209a5680fdbe0ad51f40cb1af4bdd0a010b", [:mix], [], "hexpm", "8663444fa85101a117887c170204d4c5a2182567e5f84767f0071cf15f2efb1e"}, + "ymlr": {:hex, :ymlr, "5.1.4", "b924d61e1fc1ec371cde6ab3ccd9311110b1e052fc5c2460fb322e8380e7712a", [:mix], [], "hexpm", "75f16cf0709fbd911b30311a0359a7aa4b5476346c01882addefd5f2b1cfaa51"}, } diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po new file mode 100644 index 0000000..7e6d755 --- /dev/null +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -0,0 +1,274 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" + +#: lib/mv_web/components/core_components.ex:482 +#, elixir-autogen, elixir-format +msgid "Actions" +msgstr "" + +#: lib/mv_web/member_live/index.ex:39 +#, elixir-autogen, elixir-format +msgid "Are you sure?" +msgstr "Bist du sicher?" + +#: lib/mv_web/components/core_components.ex:160 +#, elixir-autogen, elixir-format +msgid "Attempting to reconnect" +msgstr "Verbindung wird wiederhergestellt" + +#: lib/mv_web/member_live/form_component.ex:50 +#: lib/mv_web/member_live/index.ex:25 +#: lib/mv_web/member_live/show.ex:32 +#, elixir-autogen, elixir-format +msgid "City" +msgstr "Stadt" + +#: lib/mv_web/member_live/index.ex:41 +#, elixir-autogen, elixir-format +msgid "Delete" +msgstr "Löschen" + +#: lib/mv_web/member_live/index.ex:33 +#, elixir-autogen, elixir-format +msgid "Edit" +msgstr "Bearbeiten" + +#: lib/mv_web/member_live/index.ex:76 +#: lib/mv_web/member_live/show.ex:93 +#, elixir-autogen, elixir-format +msgid "Edit Member" +msgstr "Mitglied bearbeiten" + +#: lib/mv_web/member_live/form_component.ex:43 +#: lib/mv_web/member_live/index.ex:24 +#: lib/mv_web/member_live/show.ex:23 +#, elixir-autogen, elixir-format +msgid "Email" +msgstr "E-Mail" + +#: lib/mv_web/components/core_components.ex:151 +#, elixir-autogen, elixir-format +msgid "Error!" +msgstr "Fehler!" + +#: lib/mv_web/member_live/form_component.ex:41 +#: lib/mv_web/member_live/index.ex:22 +#: lib/mv_web/member_live/show.ex:21 +#, elixir-autogen, elixir-format +msgid "First Name" +msgstr "Vorname" + +#: lib/mv_web/components/core_components.ex:172 +#, elixir-autogen, elixir-format +msgid "Hang in there while we get back on track" +msgstr "Bitte warten, wir stellen die Verbindung wieder her." + +#: lib/mv_web/member_live/form_component.ex:47 +#: lib/mv_web/member_live/index.ex:26 +#: lib/mv_web/member_live/show.ex:29 +#, elixir-autogen, elixir-format +msgid "Join Date" +msgstr "Beitrittsdatum" + +#: lib/mv_web/member_live/form_component.ex:42 +#: lib/mv_web/member_live/index.ex:23 +#: lib/mv_web/member_live/show.ex:22 +#, elixir-autogen, elixir-format +msgid "Last Name" +msgstr "Nachname" + +#: lib/mv_web/member_live/index.ex:8 +#: lib/mv_web/member_live/index.ex:88 +#, elixir-autogen, elixir-format +msgid "Listing Members" +msgstr "Mitglieder" + +#: lib/mv_web/member_live/index.ex:11 +#: lib/mv_web/member_live/index.ex:82 +#, elixir-autogen, elixir-format +msgid "New Member" +msgstr "Neues Mitglied" + +#: lib/mv_web/member_live/index.ex:30 +#, elixir-autogen, elixir-format +msgid "Show" +msgstr "Anzeigen" + +#: lib/mv_web/components/core_components.ex:167 +#, elixir-autogen, elixir-format +msgid "Something went wrong!" +msgstr "Etwas ist schiefgelaufen!" + +#: lib/mv_web/components/core_components.ex:150 +#, elixir-autogen, elixir-format +msgid "Success!" +msgstr "Erfolg!" + +#: lib/mv_web/components/core_components.ex:155 +#, elixir-autogen, elixir-format +msgid "We can't find the internet" +msgstr "Keine Internetverbindung gefunden" + +#: lib/mv_web/components/core_components.ex:76 +#: lib/mv_web/components/core_components.ex:130 +#, elixir-autogen, elixir-format +msgid "close" +msgstr "schließen" + +#: lib/mv_web/member_live/form_component.ex:44 +#: lib/mv_web/member_live/show.ex:24 +#, elixir-autogen, elixir-format +msgid "Birth Date" +msgstr "Geburtsdatum" + +#: lib/mv_web/member_live/form_component.ex:55 +#: lib/mv_web/member_live/show.ex:38 +#, elixir-autogen, elixir-format +msgid "Custom Properties" +msgstr "Eigene Eigenschaften" + +#: lib/mv_web/member_live/form_component.ex:48 +#: lib/mv_web/member_live/show.ex:30 +#, elixir-autogen, elixir-format +msgid "Exit Date" +msgstr "Austrittsdatum" + +#: lib/mv_web/member_live/form_component.ex:52 +#: lib/mv_web/member_live/show.ex:34 +#, elixir-autogen, elixir-format +msgid "House Number" +msgstr "Hausnummer" + +#: lib/mv_web/member_live/form_component.ex:49 +#: lib/mv_web/member_live/show.ex:31 +#, elixir-autogen, elixir-format +msgid "Notes" +msgstr "Notizen" + +#: lib/mv_web/member_live/form_component.ex:45 +#: lib/mv_web/member_live/show.ex:25 +#, elixir-autogen, elixir-format +msgid "Paid" +msgstr "Bezahlt" + +#: lib/mv_web/member_live/form_component.ex:46 +#: lib/mv_web/member_live/show.ex:28 +#, elixir-autogen, elixir-format +msgid "Phone Number" +msgstr "Telefonnummer" + +#: lib/mv_web/member_live/form_component.ex:53 +#: lib/mv_web/member_live/show.ex:35 +#, elixir-autogen, elixir-format +msgid "Postal Code" +msgstr "Postleitzahl" + +#: lib/mv_web/member_live/form_component.ex:75 +#, elixir-autogen, elixir-format +msgid "Save Member" +msgstr "Mitglied speichern" + +#: lib/mv_web/member_live/form_component.ex:75 +#, elixir-autogen, elixir-format +msgid "Saving..." +msgstr "Speichern..." + +#: lib/mv_web/member_live/form_component.ex:51 +#: lib/mv_web/member_live/show.ex:33 +#, elixir-autogen, elixir-format +msgid "Street" +msgstr "Straße" + +#: lib/mv_web/member_live/form_component.ex:30 +#, elixir-autogen, elixir-format +msgid "Use this form to manage member records and their properties." +msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenschaften." + +#: lib/mv_web/member_live/show.ex:52 +#, elixir-autogen, elixir-format +msgid "Back to members" +msgstr "Zurück zur Mitgliederliste" + +#: lib/mv_web/member_live/show.ex:14 +#, elixir-autogen, elixir-format +msgid "Edit member" +msgstr "Mitglied bearbeiten" + +#: lib/mv_web/member_live/show.ex:20 +#, elixir-autogen, elixir-format +msgid "Id" +msgstr "ID" + +#: lib/mv_web/member_live/show.ex:26 +#, elixir-autogen, elixir-format +msgid "No" +msgstr "Nein" + +#: lib/mv_web/member_live/show.ex:92 +#, elixir-autogen, elixir-format, fuzzy +msgid "Show Member" +msgstr "Mitglied anzeigen" + +#: lib/mv_web/member_live/show.ex:10 +#, elixir-autogen, elixir-format +msgid "This is a member record from your database." +msgstr "Dies ist ein Mitglied aus deiner Datenbank." + +#: lib/mv_web/member_live/show.ex:26 +#, elixir-autogen, elixir-format +msgid "Yes" +msgstr "Ja" + +#: lib/mv_web/member_live/form_component.ex:102 +#, elixir-autogen, elixir-format +msgid "create" +msgstr "erstellt" + +#: lib/mv_web/member_live/form_component.ex:103 +#, elixir-autogen, elixir-format +msgid "update" +msgstr "aktualisiert" + +#: lib/mv_web/controllers/auth_controller.ex:43 +#, elixir-autogen, elixir-format +msgid "Incorrect email or password" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:109 +#, elixir-autogen, elixir-format +msgid "Member %{action} successfully" +msgstr "Mitglied %{action} erfolgreich" + +#: lib/mv_web/controllers/auth_controller.ex:14 +#, elixir-autogen, elixir-format +msgid "You are now signed in" +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:56 +#, elixir-autogen, elixir-format +msgid "You are now signed out" +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:36 +#, elixir-autogen, elixir-format +msgid "You have already signed in another way, but have not confirmed your account.\nYou can confirm your account using the link we sent to you, or by resetting your password.\n" +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:12 +#, elixir-autogen, elixir-format +msgid "Your email address has now been confirmed" +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:13 +#, elixir-autogen, elixir-format +msgid "Your password has successfully been reset" +msgstr "" diff --git a/priv/gettext/de/LC_MESSAGES/errors.po b/priv/gettext/de/LC_MESSAGES/errors.po new file mode 100644 index 0000000..844c4f5 --- /dev/null +++ b/priv/gettext/de/LC_MESSAGES/errors.po @@ -0,0 +1,112 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" + +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot new file mode 100644 index 0000000..aad2ae9 --- /dev/null +++ b/priv/gettext/default.pot @@ -0,0 +1,275 @@ +## This file is a PO Template file. +## +## "msgid"s here are often extracted from source code. +## Add new messages manually only if they're dynamic +## messages that can't be statically extracted. +## +## Run "mix gettext.extract" to bring this file up to +## date. Leave "msgstr"s empty as changing them here has no +## effect: edit them in PO (.po) files instead. +# +msgid "" +msgstr "" + +#: lib/mv_web/components/core_components.ex:482 +#, elixir-autogen, elixir-format +msgid "Actions" +msgstr "" + +#: lib/mv_web/member_live/index.ex:39 +#, elixir-autogen, elixir-format +msgid "Are you sure?" +msgstr "" + +#: lib/mv_web/components/core_components.ex:160 +#, elixir-autogen, elixir-format +msgid "Attempting to reconnect" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:50 +#: lib/mv_web/member_live/index.ex:25 +#: lib/mv_web/member_live/show.ex:32 +#, elixir-autogen, elixir-format +msgid "City" +msgstr "" + +#: lib/mv_web/member_live/index.ex:41 +#, elixir-autogen, elixir-format +msgid "Delete" +msgstr "" + +#: lib/mv_web/member_live/index.ex:33 +#, elixir-autogen, elixir-format +msgid "Edit" +msgstr "" + +#: lib/mv_web/member_live/index.ex:76 +#: lib/mv_web/member_live/show.ex:93 +#, elixir-autogen, elixir-format +msgid "Edit Member" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:43 +#: lib/mv_web/member_live/index.ex:24 +#: lib/mv_web/member_live/show.ex:23 +#, elixir-autogen, elixir-format +msgid "Email" +msgstr "" + +#: lib/mv_web/components/core_components.ex:151 +#, elixir-autogen, elixir-format +msgid "Error!" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:41 +#: lib/mv_web/member_live/index.ex:22 +#: lib/mv_web/member_live/show.ex:21 +#, elixir-autogen, elixir-format +msgid "First Name" +msgstr "" + +#: lib/mv_web/components/core_components.ex:172 +#, elixir-autogen, elixir-format +msgid "Hang in there while we get back on track" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:47 +#: lib/mv_web/member_live/index.ex:26 +#: lib/mv_web/member_live/show.ex:29 +#, elixir-autogen, elixir-format +msgid "Join Date" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:42 +#: lib/mv_web/member_live/index.ex:23 +#: lib/mv_web/member_live/show.ex:22 +#, elixir-autogen, elixir-format +msgid "Last Name" +msgstr "" + +#: lib/mv_web/member_live/index.ex:8 +#: lib/mv_web/member_live/index.ex:88 +#, elixir-autogen, elixir-format +msgid "Listing Members" +msgstr "" + +#: lib/mv_web/member_live/index.ex:11 +#: lib/mv_web/member_live/index.ex:82 +#, elixir-autogen, elixir-format +msgid "New Member" +msgstr "" + +#: lib/mv_web/member_live/index.ex:30 +#, elixir-autogen, elixir-format +msgid "Show" +msgstr "" + +#: lib/mv_web/components/core_components.ex:167 +#, elixir-autogen, elixir-format +msgid "Something went wrong!" +msgstr "" + +#: lib/mv_web/components/core_components.ex:150 +#, elixir-autogen, elixir-format +msgid "Success!" +msgstr "" + +#: lib/mv_web/components/core_components.ex:155 +#, elixir-autogen, elixir-format +msgid "We can't find the internet" +msgstr "" + +#: lib/mv_web/components/core_components.ex:76 +#: lib/mv_web/components/core_components.ex:130 +#, elixir-autogen, elixir-format +msgid "close" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:44 +#: lib/mv_web/member_live/show.ex:24 +#, elixir-autogen, elixir-format +msgid "Birth Date" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:55 +#: lib/mv_web/member_live/show.ex:38 +#, elixir-autogen, elixir-format +msgid "Custom Properties" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:48 +#: lib/mv_web/member_live/show.ex:30 +#, elixir-autogen, elixir-format +msgid "Exit Date" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:52 +#: lib/mv_web/member_live/show.ex:34 +#, elixir-autogen, elixir-format +msgid "House Number" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:49 +#: lib/mv_web/member_live/show.ex:31 +#, elixir-autogen, elixir-format +msgid "Notes" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:45 +#: lib/mv_web/member_live/show.ex:25 +#, elixir-autogen, elixir-format +msgid "Paid" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:46 +#: lib/mv_web/member_live/show.ex:28 +#, elixir-autogen, elixir-format +msgid "Phone Number" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:53 +#: lib/mv_web/member_live/show.ex:35 +#, elixir-autogen, elixir-format +msgid "Postal Code" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:75 +#, elixir-autogen, elixir-format +msgid "Save Member" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:75 +#, elixir-autogen, elixir-format +msgid "Saving..." +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:51 +#: lib/mv_web/member_live/show.ex:33 +#, elixir-autogen, elixir-format +msgid "Street" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:30 +#, elixir-autogen, elixir-format +msgid "Use this form to manage member records and their properties." +msgstr "" + +#: lib/mv_web/member_live/show.ex:52 +#, elixir-autogen, elixir-format +msgid "Back to members" +msgstr "" + +#: lib/mv_web/member_live/show.ex:14 +#, elixir-autogen, elixir-format +msgid "Edit member" +msgstr "" + +#: lib/mv_web/member_live/show.ex:20 +#, elixir-autogen, elixir-format +msgid "Id" +msgstr "" + +#: lib/mv_web/member_live/show.ex:26 +#, elixir-autogen, elixir-format +msgid "No" +msgstr "" + +#: lib/mv_web/member_live/show.ex:92 +#, elixir-autogen, elixir-format +msgid "Show Member" +msgstr "" + +#: lib/mv_web/member_live/show.ex:10 +#, elixir-autogen, elixir-format +msgid "This is a member record from your database." +msgstr "" + +#: lib/mv_web/member_live/show.ex:26 +#, elixir-autogen, elixir-format +msgid "Yes" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:102 +#, elixir-autogen, elixir-format +msgid "create" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:103 +#, elixir-autogen, elixir-format +msgid "update" +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:43 +#, elixir-autogen, elixir-format +msgid "Incorrect email or password" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:109 +#, elixir-autogen, elixir-format +msgid "Member %{action} successfully" +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:14 +#, elixir-autogen, elixir-format +msgid "You are now signed in" +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:56 +#, elixir-autogen, elixir-format +msgid "You are now signed out" +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:36 +#, elixir-autogen, elixir-format +msgid "You have already signed in another way, but have not confirmed your account.\nYou can confirm your account using the link we sent to you, or by resetting your password.\n" +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:12 +#, elixir-autogen, elixir-format +msgid "Your email address has now been confirmed" +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:13 +#, elixir-autogen, elixir-format +msgid "Your password has successfully been reset" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po new file mode 100644 index 0000000..3317236 --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -0,0 +1,275 @@ +## "msgid"s in this file come from POT (.pot) files. +### +### Do not add, change, or remove "msgid"s manually here as +### they're tied to the ones in the corresponding POT file +### (with the same domain). +### +### Use "mix gettext.extract --merge" or "mix gettext.merge" +### to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: lib/mv_web/components/core_components.ex:482 +#, elixir-autogen, elixir-format +msgid "Actions" +msgstr "" + +#: lib/mv_web/member_live/index.ex:39 +#, elixir-autogen, elixir-format +msgid "Are you sure?" +msgstr "" + +#: lib/mv_web/components/core_components.ex:160 +#, elixir-autogen, elixir-format +msgid "Attempting to reconnect" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:50 +#: lib/mv_web/member_live/index.ex:25 +#: lib/mv_web/member_live/show.ex:32 +#, elixir-autogen, elixir-format +msgid "City" +msgstr "" + +#: lib/mv_web/member_live/index.ex:41 +#, elixir-autogen, elixir-format +msgid "Delete" +msgstr "" + +#: lib/mv_web/member_live/index.ex:33 +#, elixir-autogen, elixir-format +msgid "Edit" +msgstr "" + +#: lib/mv_web/member_live/index.ex:76 +#: lib/mv_web/member_live/show.ex:93 +#, elixir-autogen, elixir-format +msgid "Edit Member" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:43 +#: lib/mv_web/member_live/index.ex:24 +#: lib/mv_web/member_live/show.ex:23 +#, elixir-autogen, elixir-format +msgid "Email" +msgstr "" + +#: lib/mv_web/components/core_components.ex:151 +#, elixir-autogen, elixir-format +msgid "Error!" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:41 +#: lib/mv_web/member_live/index.ex:22 +#: lib/mv_web/member_live/show.ex:21 +#, elixir-autogen, elixir-format +msgid "First Name" +msgstr "" + +#: lib/mv_web/components/core_components.ex:172 +#, elixir-autogen, elixir-format +msgid "Hang in there while we get back on track" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:47 +#: lib/mv_web/member_live/index.ex:26 +#: lib/mv_web/member_live/show.ex:29 +#, elixir-autogen, elixir-format +msgid "Join Date" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:42 +#: lib/mv_web/member_live/index.ex:23 +#: lib/mv_web/member_live/show.ex:22 +#, elixir-autogen, elixir-format +msgid "Last Name" +msgstr "" + +#: lib/mv_web/member_live/index.ex:8 +#: lib/mv_web/member_live/index.ex:88 +#, elixir-autogen, elixir-format +msgid "Listing Members" +msgstr "" + +#: lib/mv_web/member_live/index.ex:11 +#: lib/mv_web/member_live/index.ex:82 +#, elixir-autogen, elixir-format +msgid "New Member" +msgstr "" + +#: lib/mv_web/member_live/index.ex:30 +#, elixir-autogen, elixir-format +msgid "Show" +msgstr "" + +#: lib/mv_web/components/core_components.ex:167 +#, elixir-autogen, elixir-format +msgid "Something went wrong!" +msgstr "" + +#: lib/mv_web/components/core_components.ex:150 +#, elixir-autogen, elixir-format +msgid "Success!" +msgstr "" + +#: lib/mv_web/components/core_components.ex:155 +#, elixir-autogen, elixir-format +msgid "We can't find the internet" +msgstr "" + +#: lib/mv_web/components/core_components.ex:76 +#: lib/mv_web/components/core_components.ex:130 +#, elixir-autogen, elixir-format +msgid "close" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:44 +#: lib/mv_web/member_live/show.ex:24 +#, elixir-autogen, elixir-format +msgid "Birth Date" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:55 +#: lib/mv_web/member_live/show.ex:38 +#, elixir-autogen, elixir-format +msgid "Custom Properties" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:48 +#: lib/mv_web/member_live/show.ex:30 +#, elixir-autogen, elixir-format +msgid "Exit Date" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:52 +#: lib/mv_web/member_live/show.ex:34 +#, elixir-autogen, elixir-format +msgid "House Number" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:49 +#: lib/mv_web/member_live/show.ex:31 +#, elixir-autogen, elixir-format +msgid "Notes" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:45 +#: lib/mv_web/member_live/show.ex:25 +#, elixir-autogen, elixir-format +msgid "Paid" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:46 +#: lib/mv_web/member_live/show.ex:28 +#, elixir-autogen, elixir-format +msgid "Phone Number" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:53 +#: lib/mv_web/member_live/show.ex:35 +#, elixir-autogen, elixir-format +msgid "Postal Code" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:75 +#, elixir-autogen, elixir-format, fuzzy +msgid "Save Member" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:75 +#, elixir-autogen, elixir-format +msgid "Saving..." +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:51 +#: lib/mv_web/member_live/show.ex:33 +#, elixir-autogen, elixir-format +msgid "Street" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:30 +#, elixir-autogen, elixir-format +msgid "Use this form to manage member records and their properties." +msgstr "" + +#: lib/mv_web/member_live/show.ex:52 +#, elixir-autogen, elixir-format +msgid "Back to members" +msgstr "" + +#: lib/mv_web/member_live/show.ex:14 +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit member" +msgstr "" + +#: lib/mv_web/member_live/show.ex:20 +#, elixir-autogen, elixir-format +msgid "Id" +msgstr "" + +#: lib/mv_web/member_live/show.ex:26 +#, elixir-autogen, elixir-format +msgid "No" +msgstr "" + +#: lib/mv_web/member_live/show.ex:92 +#, elixir-autogen, elixir-format, fuzzy +msgid "Show Member" +msgstr "" + +#: lib/mv_web/member_live/show.ex:10 +#, elixir-autogen, elixir-format +msgid "This is a member record from your database." +msgstr "" + +#: lib/mv_web/member_live/show.ex:26 +#, elixir-autogen, elixir-format +msgid "Yes" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:102 +#, elixir-autogen, elixir-format +msgid "create" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:103 +#, elixir-autogen, elixir-format +msgid "update" +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:43 +#, elixir-autogen, elixir-format +msgid "Incorrect email or password" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:109 +#, elixir-autogen, elixir-format +msgid "Member %{action} successfully" +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:14 +#, elixir-autogen, elixir-format +msgid "You are now signed in" +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:56 +#, elixir-autogen, elixir-format +msgid "You are now signed out" +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:36 +#, elixir-autogen, elixir-format +msgid "You have already signed in another way, but have not confirmed your account.\nYou can confirm your account using the link we sent to you, or by resetting your password.\n" +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:12 +#, elixir-autogen, elixir-format +msgid "Your email address has now been confirmed" +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:13 +#, elixir-autogen, elixir-format +msgid "Your password has successfully been reset" +msgstr "" diff --git a/priv/repo/migrations/20250514151922_initial_migration.exs b/priv/repo/migrations/20250528163901_initial_migration.exs similarity index 96% rename from priv/repo/migrations/20250514151922_initial_migration.exs rename to priv/repo/migrations/20250528163901_initial_migration.exs index 64915c2..bf36e43 100644 --- a/priv/repo/migrations/20250514151922_initial_migration.exs +++ b/priv/repo/migrations/20250528163901_initial_migration.exs @@ -11,7 +11,7 @@ defmodule Mv.Repo.Migrations.InitialMigration do create table(:property_types, primary_key: false) do add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true add :name, :text, null: false - add :type, :text, null: false + add :value_type, :text, null: false add :description, :text add :immutable, :boolean, null: false, default: false add :required, :boolean, null: false, default: false @@ -21,7 +21,7 @@ defmodule Mv.Repo.Migrations.InitialMigration do create table(:properties, primary_key: false) do add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true - add :value, :text + add :value, :map add :member_id, :uuid add :property_type_id, :uuid end diff --git a/priv/repo/migrations/20250617090641_member_fields.exs b/priv/repo/migrations/20250617090641_member_fields.exs new file mode 100644 index 0000000..36a80eb --- /dev/null +++ b/priv/repo/migrations/20250617090641_member_fields.exs @@ -0,0 +1,45 @@ +defmodule Mv.Repo.Migrations.MemberFields do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:members) do + add :first_name, :text, null: false + add :last_name, :text, null: false + add :email, :text, null: false + add :birth_date, :date + add :paid, :boolean + add :phone_number, :text + add :join_date, :date + add :exit_date, :date + add :notes, :text + add :city, :text + add :street, :text + add :house_number, :text + add :postal_code, :text + end + end + + def down do + alter table(:members) do + remove :postal_code + remove :house_number + remove :street + remove :city + remove :notes + remove :exit_date + remove :join_date + remove :phone_number + remove :paid + remove :birth_date + remove :email + remove :last_name + remove :first_name + end + end +end diff --git a/priv/repo/migrations/20250617132424_member_delete.exs b/priv/repo/migrations/20250617132424_member_delete.exs new file mode 100644 index 0000000..f0f539a --- /dev/null +++ b/priv/repo/migrations/20250617132424_member_delete.exs @@ -0,0 +1,38 @@ +defmodule Mv.Repo.Migrations.MemberDelete do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + drop constraint(:properties, "properties_member_id_fkey") + + alter table(:properties) do + modify :member_id, + references(:members, + column: :id, + name: "properties_member_id_fkey", + type: :uuid, + prefix: "public", + on_delete: :delete_all + ) + end + end + + def down do + drop constraint(:properties, "properties_member_id_fkey") + + alter table(:properties) do + modify :member_id, + references(:members, + column: :id, + name: "properties_member_id_fkey", + type: :uuid, + prefix: "public" + ) + end + end +end diff --git a/priv/repo/migrations/20250620110849_add_accounts_domain_extensions.exs b/priv/repo/migrations/20250620110849_add_accounts_domain_extensions.exs new file mode 100644 index 0000000..f77419c --- /dev/null +++ b/priv/repo/migrations/20250620110849_add_accounts_domain_extensions.exs @@ -0,0 +1,19 @@ +defmodule Mv.Repo.Migrations.AddAccountsDomainExtensions do + @moduledoc """ + Installs any extensions that are mentioned in the repo's `installed_extensions/0` callback + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + execute("CREATE EXTENSION IF NOT EXISTS \"citext\"") + end + + def down do + # Uncomment this if you actually want to uninstall the extensions + # when this migration is rolled back: + # execute("DROP EXTENSION IF EXISTS \"citext\"") + end +end diff --git a/priv/repo/migrations/20250620110850_add_accounts_domain.exs b/priv/repo/migrations/20250620110850_add_accounts_domain.exs new file mode 100644 index 0000000..4c7c54b --- /dev/null +++ b/priv/repo/migrations/20250620110850_add_accounts_domain.exs @@ -0,0 +1,58 @@ +defmodule Mv.Repo.Migrations.AddAccountsDomain do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create table(:users, primary_key: false) do + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + add :email, :citext, null: false + add :hashed_password, :text + add :oidc_id, :text + + add :member_id, + references(:members, + column: :id, + name: "users_member_id_fkey", + type: :uuid, + prefix: "public" + ) + end + + create unique_index(:users, [:email], name: "users_unique_email_index") + + create unique_index(:users, [:oidc_id], name: "users_unique_oidc_id_index") + + create table(:tokens, primary_key: false) do + add :updated_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :created_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :extra_data, :map + add :purpose, :text, null: false + add :expires_at, :utc_datetime, null: false + add :subject, :text, null: false + add :jti, :text, null: false, primary_key: true + end + end + + def down do + drop table(:tokens) + + drop_if_exists unique_index(:users, [:oidc_id], name: "users_unique_oidc_id_index") + + drop_if_exists unique_index(:users, [:email], name: "users_unique_email_index") + + drop constraint(:users, "users_member_id_fkey") + + drop table(:users) + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 8fca144..1497096 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -2,38 +2,42 @@ # # mix run priv/repo/seeds.exs # -# Inside the script, you can read and write to any of your -# repositories directly: -# -# Mv.Repo.insert!(%Mv.SomeSchema{}) -# -# We recommend using the bang functions (`insert!`, `update!` -# and so on) as they will fail if something goes wrong. alias Mv.Membership for attrs <- [ %{ - name: "Vorname", - type: "string", - description: "Vorname des Mitglieds", + name: "String Field", + value_type: :string, + description: "Example for a field of type string", immutable: true, required: true }, %{ - name: "Email", - type: "string", - description: "Email-Adresse des Mitglieds", + name: "Date Field", + value_type: :date, + description: "Example for a field of type date", + immutable: true, + required: true + }, + %{ + name: "Boolean Field", + value_type: :boolean, + description: "Example for a field of type boolean", + immutable: true, + required: true + }, + %{ + name: "Email Field", + value_type: :email, + description: "Example for a field of type email", immutable: true, required: true } ] do - # upsert?: true sorgt dafür, dass bei bestehendem Namen kein Fehler, - # sondern ein Update (hier effektiv No-Op) ausgeführt wird - {:ok, _} = - Membership.create_property_type( - attrs, - upsert?: true, - upsert_identity: :unique_name - ) + Membership.create_property_type!( + attrs, + upsert?: true, + upsert_identity: :unique_name + ) end diff --git a/priv/resource_snapshots/repo/extensions.json b/priv/resource_snapshots/repo/extensions.json index 33001db..323661b 100644 --- a/priv/resource_snapshots/repo/extensions.json +++ b/priv/resource_snapshots/repo/extensions.json @@ -1,6 +1,7 @@ { "ash_functions_version": 5, "installed": [ - "ash-functions" + "ash-functions", + "citext" ] } \ No newline at end of file diff --git a/priv/resource_snapshots/repo/members/20250514151922.json b/priv/resource_snapshots/repo/members/20250528163901.json similarity index 87% rename from priv/resource_snapshots/repo/members/20250514151922.json rename to priv/resource_snapshots/repo/members/20250528163901.json index df811d8..2d08b17 100644 --- a/priv/resource_snapshots/repo/members/20250514151922.json +++ b/priv/resource_snapshots/repo/members/20250528163901.json @@ -16,7 +16,7 @@ "custom_indexes": [], "custom_statements": [], "has_create_action": true, - "hash": "A0402269CB456075B81CA4CB3A2135A2C88D8B7FD51CD7A23084AA5264FEE344", + "hash": "35D45214D6D344B0AF6CFCB69B8682FCB3D382D85883D3D3AAC1AEE7F54FD89A", "identities": [], "multitenancy": { "attribute": null, diff --git a/priv/resource_snapshots/repo/members/20250617090641.json b/priv/resource_snapshots/repo/members/20250617090641.json new file mode 100644 index 0000000..9e2d991 --- /dev/null +++ b/priv/resource_snapshots/repo/members/20250617090641.json @@ -0,0 +1,187 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "first_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "birth_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "paid", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "phone_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "join_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "exit_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "notes", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "city", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "street", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "house_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "postal_code", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "CF80317E7EE409618E08458B10EE122FF605640DDA8CD6000B433F1979614F5D", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "members" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/properties/20250514151922.json b/priv/resource_snapshots/repo/properties/20250528163901.json similarity index 95% rename from priv/resource_snapshots/repo/properties/20250514151922.json rename to priv/resource_snapshots/repo/properties/20250528163901.json index 0707bcf..daf3222 100644 --- a/priv/resource_snapshots/repo/properties/20250514151922.json +++ b/priv/resource_snapshots/repo/properties/20250528163901.json @@ -18,7 +18,7 @@ "references": null, "size": null, "source": "value", - "type": "text" + "type": "map" }, { "allow_nil?": true, @@ -84,7 +84,7 @@ "custom_indexes": [], "custom_statements": [], "has_create_action": true, - "hash": "F2A42A3427E0428637F465E4F357A3BE21B33231F94CF77B4843084128F6BDA5", + "hash": "8CF241CB9E8239511914EDEC96186BB7879529372BD8A4162431CCE9961F4F1B", "identities": [], "multitenancy": { "attribute": null, diff --git a/priv/resource_snapshots/repo/properties/20250617132424.json b/priv/resource_snapshots/repo/properties/20250617132424.json new file mode 100644 index 0000000..49e3b48 --- /dev/null +++ b/priv/resource_snapshots/repo/properties/20250617132424.json @@ -0,0 +1,105 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "value", + "type": "map" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "properties_member_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "members" + }, + "scale": null, + "size": null, + "source": "member_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "properties_property_type_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "property_types" + }, + "scale": null, + "size": null, + "source": "property_type_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "4F17BE0106435A1D75D46A3ABDE6A3DA20FC9B1C43D101B6C310009279DD7CBA", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "properties" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/property_types/20250514151922.json b/priv/resource_snapshots/repo/property_types/20250528163901.json similarity index 94% rename from priv/resource_snapshots/repo/property_types/20250514151922.json rename to priv/resource_snapshots/repo/property_types/20250528163901.json index 5da5fa8..55f066a 100644 --- a/priv/resource_snapshots/repo/property_types/20250514151922.json +++ b/priv/resource_snapshots/repo/property_types/20250528163901.json @@ -27,7 +27,7 @@ "primary_key?": false, "references": null, "size": null, - "source": "type", + "source": "value_type", "type": "text" }, { @@ -66,7 +66,7 @@ "custom_indexes": [], "custom_statements": [], "has_create_action": true, - "hash": "47210108DE1E7B2A20A67205E875B3440526941E61AB95B166976E8CD8AA0955", + "hash": "F98A723AE0D20005FBE4205E46ABEE09A88DFF9334C85BADC1FBEEF100F3E25B", "identities": [ { "all_tenants?": false, diff --git a/priv/resource_snapshots/repo/tokens/20250620110850.json b/priv/resource_snapshots/repo/tokens/20250620110850.json new file mode 100644 index 0000000..c702eff --- /dev/null +++ b/priv/resource_snapshots/repo/tokens/20250620110850.json @@ -0,0 +1,103 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "created_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "extra_data", + "type": "map" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "purpose", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "expires_at", + "type": "utc_datetime" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "subject", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "jti", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "EA1475C339B5BE2728560EFB2AF911275B2F65C2CE66CD1C093FAB5D9183BB11", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "tokens" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/users/20250620110850.json b/priv/resource_snapshots/repo/users/20250620110850.json new file mode 100644 index 0000000..54688a8 --- /dev/null +++ b/priv/resource_snapshots/repo/users/20250620110850.json @@ -0,0 +1,127 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "citext" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "hashed_password", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "oidc_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "users_member_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "members" + }, + "scale": null, + "size": null, + "source": "member_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "03EBA1A8BCE47C4706E2D718E00364465E08C9A3999988D49FC1B89DEC5D717C", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "users_unique_email_index", + "keys": [ + { + "type": "atom", + "value": "email" + } + ], + "name": "unique_email", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "users_unique_oidc_id_index", + "keys": [ + { + "type": "atom", + "value": "oidc_id" + } + ], + "name": "unique_oidc_id", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "users" +} \ No newline at end of file diff --git a/renovate.json b/renovate.json index 7e12828..d1281a3 100644 --- a/renovate.json +++ b/renovate.json @@ -17,9 +17,14 @@ "matchDatasources": ["docker"] }, { - "matchFileNames": [".tool-versions", "Dockerfile"], + "description": "Disable elixir updates, as renovate does not work with their -otp- numbering scheme.", "matchCurrentValue": "**-otp-**", "enabled": false + }, + { + "description": "Disable erlang updates as they need to be coordinated with elixir updates.", + "matchDepNames": "erlang", + "enabled": false } ] } diff --git a/test/membership/member_test.exs b/test/membership/member_test.exs new file mode 100644 index 0000000..7015d34 --- /dev/null +++ b/test/membership/member_test.exs @@ -0,0 +1,110 @@ +defmodule Mv.Membership.MemberTest do + use Mv.DataCase, async: false + alias Mv.Membership + + describe "Fields and Validations" do + @valid_attrs %{ + first_name: "John", + last_name: "Doe", + birth_date: ~D[1990-01-01], + paid: true, + email: "john@example.com", + phone_number: "+49123456789", + join_date: ~D[2020-01-01], + exit_date: nil, + notes: "Test note", + city: "Berlin", + street: "Main Street", + house_number: "1A", + postal_code: "12345" + } + + test "First name is required and must not be empty" do + attrs = Map.put(@valid_attrs, :first_name, "") + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :first_name) =~ "must be present" + end + + test "Last name is required and must not be empty" do + attrs = Map.put(@valid_attrs, :last_name, "") + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :last_name) =~ "must be present" + end + + test "Email is required" do + attrs = Map.put(@valid_attrs, :email, "") + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :email) =~ "must be present" + end + + test "Email must be valid" do + attrs = Map.put(@valid_attrs, :email, "test@") + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :email) =~ "is not a valid email" + end + + test "Birth date is optional but must not be in the future" do + attrs = Map.put(@valid_attrs, :birth_date, Date.utc_today() |> Date.add(1)) + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :birth_date) =~ "cannot be in the future" + end + + test "Paid is optional but must be boolean if specified" do + attrs = Map.put(@valid_attrs, :paid, nil) + attrs2 = Map.put(@valid_attrs, :paid, "yes") + assert {:ok, _member} = Membership.create_member(Map.delete(attrs, :paid)) + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs2) + assert error_message(errors, :paid) =~ "is invalid" + end + + test "Phone number is optional but must have a valid format if specified" do + attrs = Map.put(@valid_attrs, :phone_number, "abc") + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :phone_number) =~ "is not a valid phone number" + attrs2 = Map.delete(@valid_attrs, :phone_number) + assert {:ok, _member} = Membership.create_member(attrs2) + end + + test "Join date is optional but must not be in the future" do + attrs = Map.put(@valid_attrs, :join_date, Date.utc_today() |> Date.add(1)) + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :join_date) =~ "cannot be in the future" + attrs2 = Map.delete(@valid_attrs, :join_date) + assert {:ok, _member} = Membership.create_member(attrs2) + end + + test "Exit date is optional but must not be before join date if both are specified" do + attrs = Map.put(@valid_attrs, :exit_date, ~D[2010-01-01]) + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :exit_date) =~ "cannot be before join date" + attrs2 = Map.delete(@valid_attrs, :exit_date) + assert {:ok, _member} = Membership.create_member(attrs2) + end + + test "Notes is optional" do + attrs = Map.delete(@valid_attrs, :notes) + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "City, street, house number are optional" do + attrs = @valid_attrs |> Map.drop([:city, :street, :house_number]) + assert {:ok, _member} = Membership.create_member(attrs) + end + + test "Postal code is optional but must have 5 digits if specified" do + attrs = Map.put(@valid_attrs, :postal_code, "1234") + assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) + assert error_message(errors, :postal_code) =~ "must consist of 5 digits" + attrs2 = Map.delete(@valid_attrs, :postal_code) + assert {:ok, _member} = Membership.create_member(attrs2) + end + end + + # Helper function for error evaluation + defp error_message(errors, field) do + errors + |> Enum.filter(fn err -> Map.get(err, :field) == field end) + |> Enum.map(&Map.get(&1, :message, "")) + |> List.first() + end +end diff --git a/test/mv_web/controllers/auth_controller_test.exs b/test/mv_web/controllers/auth_controller_test.exs new file mode 100644 index 0000000..e18ade2 --- /dev/null +++ b/test/mv_web/controllers/auth_controller_test.exs @@ -0,0 +1,48 @@ +defmodule MvWeb.AuthControllerTest do + use MvWeb.ConnCase, async: true + + test "GET /sign-in shows sign in form", %{conn: conn} do + conn = get(conn, ~p"/sign-in") + assert html_response(conn, 200) =~ "Sign in" + end + + test "POST /sign-in with valid credentials redirects to home", %{conn: conn} do + # Create a test user first + conn = conn_with_oidc_user(conn) + conn = get(conn, ~p"/sign-in") + + assert redirected_to(conn) == ~p"/" + end + + test "POST /sign-in with invalid credentials shows error", %{conn: conn} do + conn = + post(conn, ~p"/auth/sign_in", %{ + "user" => %{ + "email" => "wrong@example.com", + "password" => "wrongpassword" + } + }) + + assert conn.status == 404 + end + + test "GET /sign-out redirects to home", %{conn: conn} do + # First sign in a user + conn = conn_with_oidc_user(conn) + + # Then sign out + conn = get(conn, ~p"/sign-out") + assert redirected_to(conn) == ~p"/" + end + + test "unauthenticated user accessing protected route gets redirected to sign-in", %{conn: conn} do + conn = get(conn, ~p"/members") + assert redirected_to(conn) == ~p"/sign-in" + end + + test "authenticated user can access protected route", %{conn: conn} do + conn = conn_with_oidc_user(conn) + conn = get(conn, ~p"/members") + assert conn.status == 200 + end +end diff --git a/test/mv_web/controllers/page_controller_test.exs b/test/mv_web/controllers/page_controller_test.exs index 702cd78..ce3195b 100644 --- a/test/mv_web/controllers/page_controller_test.exs +++ b/test/mv_web/controllers/page_controller_test.exs @@ -2,7 +2,9 @@ defmodule MvWeb.PageControllerTest do use MvWeb.ConnCase test "GET /", %{conn: conn} do + conn = conn_with_oidc_user(conn) + conn = get(conn, ~p"/") - assert html_response(conn, 200) =~ "Peace of mind from prototype to production" + assert html_response(conn, 200) =~ "Mitgliederverwaltung" end end diff --git a/test/mv_web/locale_test.exs b/test/mv_web/locale_test.exs new file mode 100644 index 0000000..1cc6693 --- /dev/null +++ b/test/mv_web/locale_test.exs @@ -0,0 +1,14 @@ +defmodule MvWeb.LocaleTest do + use MvWeb.ConnCase, async: true + import Phoenix.ConnTest + + test "language switch via form sets the locale to English in the session" do + conn = post(build_conn(), "/set_locale", %{"locale" => "en"}) + assert get_session(conn, :locale) == "en" + end + + test "language switch via form sets the locale to German in the session" do + conn = post(build_conn(), "/set_locale", %{"locale" => "de"}) + assert get_session(conn, :locale) == "de" + end +end diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs new file mode 100644 index 0000000..ce47a43 --- /dev/null +++ b/test/mv_web/member_live/index_test.exs @@ -0,0 +1,66 @@ +defmodule MvWeb.MemberLive.IndexTest do + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + + test "shows translated title in German", %{conn: conn} do + conn = conn_with_oidc_user(conn) + conn = Plug.Test.init_test_session(conn, locale: "de") + {:ok, _view, html} = live(conn, "/members") + # Expected German title + assert html =~ "Mitglieder" + end + + test "shows translated title in English", %{conn: conn} do + conn = conn_with_oidc_user(conn) + Gettext.put_locale(MvWeb.Gettext, "en") + {:ok, _view, html} = live(conn, "/members") + # Expected English title + assert html =~ "Members" + end + + test "shows translated button text in German", %{conn: conn} do + conn = conn_with_oidc_user(conn) + conn = Plug.Test.init_test_session(conn, locale: "de") + {:ok, _view, html} = live(conn, "/members/new") + assert html =~ "Speichern" + end + + test "shows translated button text in English", %{conn: conn} do + conn = conn_with_oidc_user(conn) + Gettext.put_locale(MvWeb.Gettext, "en") + {:ok, _view, html} = live(conn, "/members/new") + assert html =~ "Save" + end + + test "shows translated flash message after creating a member in German", %{conn: conn} do + conn = conn_with_oidc_user(conn) + conn = Plug.Test.init_test_session(conn, locale: "de") + {:ok, view, _html} = live(conn, "/members") + view |> element("a", "Neues Mitglied") |> render_click() + + form_data = %{ + "member[first_name]" => "Max", + "member[last_name]" => "Mustermann", + "member[email]" => "max@example.com" + } + + view |> form("#member-form", form_data) |> render_submit() + assert has_element?(view, "#flash-group", "Mitglied erstellt erfolgreich") + end + + test "shows translated flash message after creating a member in English", %{conn: conn} do + conn = conn_with_oidc_user(conn) + conn = Plug.Test.init_test_session(conn, locale: "en") + {:ok, view, _html} = live(conn, "/members") + view |> element("a", "New Member") |> render_click() + + form_data = %{ + "member[first_name]" => "Max", + "member[last_name]" => "Mustermann", + "member[email]" => "max@example.com" + } + + view |> form("#member-form", form_data) |> render_submit() + assert has_element?(view, "#flash-group", "Member create successfully") + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 7101531..d1804b7 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -31,6 +31,38 @@ defmodule MvWeb.ConnCase do end end + @doc """ + Creates a test user and returns the user struct. + """ + def create_test_user(attrs \\ %{}) do + email = "user@example.com" + password = "password" + {:ok, hashed_password} = AshAuthentication.BcryptProvider.hash(password) + + Ash.Seed.seed!(Mv.Accounts.User, %{ + email: email, + hashed_password: hashed_password + }) + end + + @doc """ + Signs in a user via OIDC for testing by creating a session with the user's token. + """ + def sign_in_user_via_oidc(conn, user) do + # Mock OIDC sign-in by creating a token directly + conn + |> Phoenix.ConnTest.init_test_session(%{}) + |> AshAuthentication.Plug.Helpers.store_in_session(user) + end + + @doc """ + Signs in a user via OIDC and returns a connection with the user authenticated. + """ + def conn_with_oidc_user(conn, user_attrs \\ %{}) do + user = create_test_user(user_attrs) + sign_in_user_via_oidc(conn, user) + end + setup tags do Mv.DataCase.setup_sandbox(tags) {:ok, conn: Phoenix.ConnTest.build_conn()}