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 040944d..eef8464 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ 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/Justfile b/Justfile index f44be78..26db3bc 100644 --- a/Justfile +++ b/Justfile @@ -1,3 +1,5 @@ +set dotenv-load := true + run: install-dependencies start-database migrate-database seed-database mix phx.server @@ -9,6 +11,7 @@ migrate-database: reset-database: mix ash.reset + MIX_ENV=test mix ash.reset seed-database: mix run priv/repo/seeds.exs @@ -18,6 +21,10 @@ start-database: ci-dev: lint audit test +gettext: + mix gettext.extract + mix gettext.merge priv/gettext + lint: mix format --check-formatted mix compile --warnings-as-errors 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..af6e92c 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -84,3 +84,14 @@ 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" diff --git a/config/runtime.exs b/config/runtime.exs index e591590..264ae16 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -53,6 +53,8 @@ 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" + config :mv, MvWeb.Endpoint, url: [host: host, port: 443, scheme: "https"], http: [ diff --git a/docker-compose.yml b/docker-compose.yml index 3b4e8ec..c41cc71 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,10 @@ +version: "3.5" + +networks: + local: + rauthy-test: + driver: bridge + services: db: image: postgres:17.5-alpine @@ -16,9 +23,46 @@ services: networks: - local -networks: - local: + mailcrab: + image: marlonb/mailcrab:latest + ports: + - "1080:1080" + networks: + - rauthy-test + + + rauthy: + container_name: rauthy-test + image: ghcr.io/sebadob/rauthy:0.30.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-test + - local + volumes: + - type: volume + source: rauthy-data + target: /app/data volumes: 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..8d2946c --- /dev/null +++ b/lib/accounts/user.ex @@ -0,0 +1,119 @@ +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 + tokens do + enabled? true + token_resource Mv.Accounts.Token + require_token_presence_for_authentication? true + 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/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 0bd5eab..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 diff --git a/lib/membership/property_type.ex b/lib/membership/property_type.ex index 8e42fa6..7444c13 100644 --- a/lib/membership/property_type.ex +++ b/lib/membership/property_type.ex @@ -21,7 +21,7 @@ defmodule Mv.Membership.PropertyType do 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..7fe229c --- /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() + # TODO: Replace with your email + |> 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..fe1cdb0 --- /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() + # TODO: Replace with your email + |> 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..e6bcb82 --- /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} -> "Your email address has now been confirmed" + {:password, :reset} -> "Your password has successfully been reset" + _ -> "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{}] + } + }} -> + """ + 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. + """ + + _ -> + "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, "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/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 101cf6c..5535d1a 100644 --- a/lib/mv_web/member_live/form_component.ex +++ b/lib/mv_web/member_live/form_component.ex @@ -26,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 @@ -36,6 +38,21 @@ 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)) %> <.inputs_for :let={value_form} field={f_property[:value]}> @@ -55,7 +72,7 @@ defmodule MvWeb.MemberLive.FormComponent do <:actions> - <.button phx-disable-with="Saving...">Save Member + <.button phx-disable-with={gettext("Saving...")}>{gettext("Save Member")}
@@ -80,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} 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..cdc517b 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,89 @@ 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] + + # 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] + + # 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] + + # 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 +128,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 fd60217..c16e11b 100644 --- a/mix.exs +++ b/mix.exs @@ -40,6 +40,9 @@ defmodule Mv.MixProject do {:ash_postgres, "~> 2.0"}, {:ash_phoenix, "~> 2.0"}, {:ash, "~> 3.0"}, + {: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"}, @@ -69,7 +72,8 @@ defmodule Mv.MixProject do {:bandit, "~> 1.5"}, {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, {:sobelow, "~> 0.14", only: [:dev, :test], runtime: false}, - {:credo, "~> 1.7", only: [:dev, :test], runtime: false} + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:ecto_commons, "~> 0.3"} ] end @@ -91,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 8e179eb..51b4604 100644 --- a/mix.lock +++ b/mix.lock @@ -1,21 +1,29 @@ %{ "ash": {:hex, :ash, "3.5.19", "defd1c6b94475352a7b69f430b792fb64e3a9f7ca030195737bb97dc0f1311b5", [: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", "ded976230b1ef823aeb25008cc62de6545bf3ad6208cf1f3badb598fa6c01375"}, "ash_admin": {:hex, :ash_admin, "0.13.9", "8a7c0f52be4aa490e4a59137bc40e3abafba9e1977f800bb2edae3f331ef1ebb", [: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", "1373e1749d6b5b21c7ff7d7fc79ac932f6f8d1bd0d154a80758eab168948ea37"}, + "ash_authentication": {:hex, :ash_authentication, "4.9.3", "2347b7982e3b00ae1165a4ef6875e05540204e933922e302bd3ac2be4c043e20", [: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.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", "1641988f6c67b7d7517caed9e6cb0f6bd906bbb994e2831022b6ad7cecf45ad0"}, + "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.10.1", "6facb8e14d7e93c3268b8cb5300d42d3802bd754d241f4215f2c5fc1d34c4c94", [: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", "efc27905b29476cacb67562658d5b38ca0656b3c81c4bcb40a117a2d8d686433"}, "ash_phoenix": {:hex, :ash_phoenix, "2.3.6", "c2bea1673af52f305b2fe0c04999bd1f0dc8e127d4757a3d7f42d0b9dea16a7a", [: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", "6923dca70fe1d533864134999f4d9c5c59ef745a6b50982d42d60c18966474cd"}, "ash_postgres": {:hex, :ash_postgres, "2.6.6", "f60f806e3e969669329dfd33068bf602f3d7f214e0bbb36c241433f34cbff2e0", [: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.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.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", "3133800432273f9e6effb6f8464fe81da22c5b577aa73291f63fd229f4bb43fb"}, "ash_sql": {:hex, :ash_sql, "0.2.80", "7717dca3794d7461b8302b107f039bce2c57773840177528cf94c7c264ed763b", [: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", "036f96b78bf612a1d1fe798b8795ab1e6ecef81e41ca473b1533b139dd0202ab"}, + "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.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"}, "circular_buffer": {:hex, :circular_buffer, "0.4.1", "477f370fd8cfe1787b0a1bade6208bbd274b34f1610e41f1180ba756a7679839", [:mix], [], "hexpm", "633ef2e059dde0d7b89bbab13b1da9d04c6685e80e68fbdf41282d4fae746b72"}, + "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"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "ecto": {:hex, :ecto, "3.12.6", "8bf762dc5b87d85b7aca7ad5fe31ef8142a84cea473a3381eb933bd925751300", [: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", "4c0cba01795463eebbcd9e4b5ef53c1ee8e68b9c482baef2a80de5a61e7a57fe"}, + "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.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"}, + "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"}, @@ -28,8 +36,11 @@ "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.2.4", "2e0b02874ca562ba2d8cebb9e024c25c0ae9c1f4ee499135a70814e1dea6183e", [: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", "bfd0db143be54ccf2872f15bfd2209fbec1083d0b06b81b4cedeecb2fa9ac208"}, + "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.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"}, @@ -39,6 +50,7 @@ "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.4", "dcf3483ab45bab4c15e3a47c34451392f64e433846b08469f5d16c2a4cd70052", [: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", "f5b8584c36ccc9b903948a696fc9b8b81102c79c7c0c751a9f00cdec55d5f2d7"}, "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, + "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"}, @@ -51,6 +63,7 @@ "reactor": {:hex, :reactor, "0.15.4", "ef0c56a901c132529a14ab59fed0ccb4fcecb24308fb189a94c908255d4fdafc", [: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", "783bf62fd0c72ded033afabdb8b6190b7048769771a2a97256e6f0bf4fb0a891"}, "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"}, "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"}, + "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.65", "4c10d109c108417ce394158f330be09ef184878bde45de6462397fbda68cec29", [: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", "d66d5070a77f4c69cb4f007e941ac17d5d751ce71190fcd6e6e5fb42ba86f101"}, @@ -58,6 +71,7 @@ "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.19.2", "b2325aa7cd2bcd63ba023fa07a73dfc4f80660a592d40912975a879966ed9b7b", [: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", "cab7ef7c2c94c68fe21d3da26f6b86db118fdf4e7024ccb5842a4972c1056837"}, + "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "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"}, diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po new file mode 100644 index 0000000..aa33cc3 --- /dev/null +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -0,0 +1,244 @@ +## `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:48 +#: lib/mv_web/member_live/index.ex:25 +#: lib/mv_web/member_live/show.ex:30 +#, 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:91 +#, elixir-autogen, elixir-format +msgid "Edit Member" +msgstr "Mitglied bearbeiten" + +#: lib/mv_web/member_live/form_component.ex:41 +#: 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:39 +#: 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:45 +#: lib/mv_web/member_live/index.ex:26 +#: lib/mv_web/member_live/show.ex:27 +#, elixir-autogen, elixir-format +msgid "Join Date" +msgstr "Beitrittsdatum" + +#: lib/mv_web/member_live/form_component.ex:40 +#: 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:42 +#: 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:53 +#: lib/mv_web/member_live/show.ex:36 +#, elixir-autogen, elixir-format +msgid "Custom Properties" +msgstr "Eigene Eigenschaften" + +#: lib/mv_web/member_live/form_component.ex:46 +#: lib/mv_web/member_live/show.ex:28 +#, elixir-autogen, elixir-format +msgid "Exit Date" +msgstr "Austrittsdatum" + +#: lib/mv_web/member_live/form_component.ex:50 +#: lib/mv_web/member_live/show.ex:32 +#, elixir-autogen, elixir-format +msgid "House Number" +msgstr "Hausnummer" + +#: lib/mv_web/member_live/form_component.ex:47 +#: lib/mv_web/member_live/show.ex:29 +#, elixir-autogen, elixir-format +msgid "Notes" +msgstr "Notizen" + +#: lib/mv_web/member_live/form_component.ex:43 +#: lib/mv_web/member_live/show.ex:25 +#, elixir-autogen, elixir-format +msgid "Paid" +msgstr "Bezahlt" + +#: lib/mv_web/member_live/form_component.ex:44 +#: lib/mv_web/member_live/show.ex:26 +#, elixir-autogen, elixir-format +msgid "Phone Number" +msgstr "Telefonnummer" + +#: lib/mv_web/member_live/form_component.ex:51 +#: lib/mv_web/member_live/show.ex:33 +#, elixir-autogen, elixir-format +msgid "Postal Code" +msgstr "Postleitzahl" + +#: lib/mv_web/member_live/form_component.ex:73 +#, elixir-autogen, elixir-format +msgid "Save Member" +msgstr "Mitglied speichern" + +#: lib/mv_web/member_live/form_component.ex:73 +#, elixir-autogen, elixir-format +msgid "Saving..." +msgstr "Speichern..." + +#: lib/mv_web/member_live/form_component.ex:49 +#: lib/mv_web/member_live/show.ex:31 +#, elixir-autogen, elixir-format +msgid "Street" +msgstr "Straße" + +#: lib/mv_web/member_live/form_component.ex:29 +#, 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:50 +#, 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:25 +#, elixir-autogen, elixir-format +msgid "No" +msgstr "Nein" + +#: lib/mv_web/member_live/show.ex:90 +#, 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:25 +#, elixir-autogen, elixir-format +msgid "Yes" +msgstr "Ja" + +#: lib/mv_web/member_live/form_component.ex:107 +#, elixir-autogen, elixir-format +msgid "Member %{action} successfully" +msgstr "Mitglied %{action} erfolgreich" + +#: lib/mv_web/member_live/form_component.ex:100 +#, elixir-autogen, elixir-format +msgid "create" +msgstr "erstellt" + +#: lib/mv_web/member_live/form_component.ex:101 +#, elixir-autogen, elixir-format +msgid "update" +msgstr "aktualisiert" diff --git a/priv/gettext/de/LC_MESSAGES/errors.po b/priv/gettext/de/LC_MESSAGES/errors.po new file mode 100644 index 0000000..c0fba6d --- /dev/null +++ b/priv/gettext/de/LC_MESSAGES/errors.po @@ -0,0 +1,133 @@ +## `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 "" + +msgid "is not a valid email" +msgstr "ist keine gültige E-Mail-Adresse" + +msgid "cannot be in the future" +msgstr "darf nicht in der Zukunft liegen" + +msgid "must be present" +msgstr "muss ausgefüllt sein" + +msgid "is not a valid phone number" +msgstr "ist keine gültige Telefonnummer" + +msgid "length must be greater than or equal to 5" +msgstr "Die Länge muss mindestens 5 Zeichen betragen" + +msgid "cannot be before join date" +msgstr "darf nicht vor dem Eintrittsdatum liegen" + +msgid "must consist of 5 digits" +msgstr "muss aus 5 Ziffern bestehen" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot new file mode 100644 index 0000000..f5b79d7 --- /dev/null +++ b/priv/gettext/default.pot @@ -0,0 +1,245 @@ +## 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:48 +#: lib/mv_web/member_live/index.ex:25 +#: lib/mv_web/member_live/show.ex:30 +#, 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:91 +#, elixir-autogen, elixir-format +msgid "Edit Member" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:41 +#: 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:39 +#: 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:45 +#: lib/mv_web/member_live/index.ex:26 +#: lib/mv_web/member_live/show.ex:27 +#, elixir-autogen, elixir-format +msgid "Join Date" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:40 +#: 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:42 +#: lib/mv_web/member_live/show.ex:24 +#, elixir-autogen, elixir-format +msgid "Birth Date" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:53 +#: lib/mv_web/member_live/show.ex:36 +#, elixir-autogen, elixir-format +msgid "Custom Properties" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:46 +#: lib/mv_web/member_live/show.ex:28 +#, elixir-autogen, elixir-format +msgid "Exit Date" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:50 +#: lib/mv_web/member_live/show.ex:32 +#, elixir-autogen, elixir-format +msgid "House Number" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:47 +#: lib/mv_web/member_live/show.ex:29 +#, elixir-autogen, elixir-format +msgid "Notes" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:43 +#: lib/mv_web/member_live/show.ex:25 +#, elixir-autogen, elixir-format +msgid "Paid" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:44 +#: lib/mv_web/member_live/show.ex:26 +#, elixir-autogen, elixir-format +msgid "Phone Number" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:51 +#: lib/mv_web/member_live/show.ex:33 +#, elixir-autogen, elixir-format +msgid "Postal Code" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:73 +#, elixir-autogen, elixir-format +msgid "Save Member" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:73 +#, elixir-autogen, elixir-format +msgid "Saving..." +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:49 +#: lib/mv_web/member_live/show.ex:31 +#, elixir-autogen, elixir-format +msgid "Street" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:29 +#, elixir-autogen, elixir-format +msgid "Use this form to manage member records and their properties." +msgstr "" + +#: lib/mv_web/member_live/show.ex:50 +#, 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:25 +#, elixir-autogen, elixir-format +msgid "No" +msgstr "" + +#: lib/mv_web/member_live/show.ex:90 +#, 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:25 +#, elixir-autogen, elixir-format +msgid "Yes" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:107 +#, elixir-autogen, elixir-format +msgid "Mitglied %{action} erfolgreich" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:100 +#, elixir-autogen, elixir-format +msgid "create" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:101 +#, elixir-autogen, elixir-format +msgid "update" +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..6173d39 --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -0,0 +1,245 @@ +## "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:48 +#: lib/mv_web/member_live/index.ex:25 +#: lib/mv_web/member_live/show.ex:30 +#, 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:91 +#, elixir-autogen, elixir-format +msgid "Edit Member" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:41 +#: 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:39 +#: 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:45 +#: lib/mv_web/member_live/index.ex:26 +#: lib/mv_web/member_live/show.ex:27 +#, elixir-autogen, elixir-format +msgid "Join Date" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:40 +#: 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:42 +#: lib/mv_web/member_live/show.ex:24 +#, elixir-autogen, elixir-format +msgid "Birth Date" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:53 +#: lib/mv_web/member_live/show.ex:36 +#, elixir-autogen, elixir-format +msgid "Custom Properties" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:46 +#: lib/mv_web/member_live/show.ex:28 +#, elixir-autogen, elixir-format +msgid "Exit Date" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:50 +#: lib/mv_web/member_live/show.ex:32 +#, elixir-autogen, elixir-format +msgid "House Number" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:47 +#: lib/mv_web/member_live/show.ex:29 +#, elixir-autogen, elixir-format +msgid "Notes" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:43 +#: lib/mv_web/member_live/show.ex:25 +#, elixir-autogen, elixir-format +msgid "Paid" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:44 +#: lib/mv_web/member_live/show.ex:26 +#, elixir-autogen, elixir-format +msgid "Phone Number" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:51 +#: lib/mv_web/member_live/show.ex:33 +#, elixir-autogen, elixir-format +msgid "Postal Code" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:73 +#, elixir-autogen, elixir-format, fuzzy +msgid "Save Member" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:73 +#, elixir-autogen, elixir-format +msgid "Saving..." +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:49 +#: lib/mv_web/member_live/show.ex:31 +#, elixir-autogen, elixir-format +msgid "Street" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:29 +#, elixir-autogen, elixir-format +msgid "Use this form to manage member records and their properties." +msgstr "" + +#: lib/mv_web/member_live/show.ex:50 +#, 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:25 +#, elixir-autogen, elixir-format +msgid "No" +msgstr "" + +#: lib/mv_web/member_live/show.ex:90 +#, 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:25 +#, elixir-autogen, elixir-format +msgid "Yes" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:107 +#, elixir-autogen, elixir-format +msgid "Member %{action} successfully" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:100 +#, elixir-autogen, elixir-format +msgid "create" +msgstr "" + +#: lib/mv_web/member_live/form_component.ex:101 +#, elixir-autogen, elixir-format +msgid "update" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po index 844c4f5..60c1037 100644 --- a/priv/gettext/en/LC_MESSAGES/errors.po +++ b/priv/gettext/en/LC_MESSAGES/errors.po @@ -110,3 +110,12 @@ msgstr "" msgid "must be equal to %{number}" msgstr "" + +msgid "length must be greater than or equal to 5" +msgstr "length must be greater than or equal to 5" + +msgid "cannot be before join date" +msgstr "cannot be before join date" + +msgid "must consist of 5 digits" +msgstr "must consist of 5 digits" 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 39327d0..1497096 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -7,37 +7,30 @@ alias Mv.Membership for attrs <- [ %{ - name: "Vorname", + name: "String Field", value_type: :string, - description: "Vorname des Mitglieds", + description: "Example for a field of type string", immutable: true, required: true }, %{ - name: "Nachname", - value_type: :string, - description: "Nachname des Mitglieds", - immutable: true, - required: true - }, - %{ - name: "Geburtsdatum", + name: "Date Field", value_type: :date, - description: "Geburtsdatum des Mitglieds", + description: "Example for a field of type date", immutable: true, required: true }, %{ - name: "Bezahlt", + name: "Boolean Field", value_type: :boolean, - description: "Status des Mitgliedsbeitrages des Mitglieds", + description: "Example for a field of type boolean", immutable: true, required: true }, %{ - name: "Email", + name: "Email Field", value_type: :email, - description: "Email-Adresse des Mitglieds", + description: "Example for a field of type email", immutable: true, required: true } 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/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/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/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/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/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..b5a5968 --- /dev/null +++ b/test/mv_web/member_live/index_test.exs @@ -0,0 +1,60 @@ +defmodule MvWeb.MemberLive.IndexTest do + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + + test "shows translated title in German", %{conn: conn} do + 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 + 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 = 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 + 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 = 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 = 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