defmodule Mixer.Accounts.User do import Ash.Expr use Ash.Resource, otp_app: :mixer, domain: Mixer.Accounts, data_layer: AshPostgres.DataLayer, authorizers: [Ash.Policy.Authorizer], extensions: [AshAuthentication, AshTypescript.Resource] authentication do add_ons do log_out_everywhere do apply_on_password_change? true end confirmation :confirm_new_user do monitor_fields [:email] confirm_on_create? true confirm_on_update? false require_interaction? true confirmed_at_field :confirmed_at auto_confirm_actions [:sign_in_with_magic_link, :reset_password_with_token] sender Mixer.Accounts.User.Senders.SendNewUserConfirmationEmail end end tokens do enabled? true token_resource Mixer.Accounts.Token signing_secret Mixer.Secrets store_all_tokens? true require_token_presence_for_authentication? true end strategies do password :password do identity_field :email hash_provider AshAuthentication.BcryptProvider require_confirmed_with :confirmed_at resettable do sender Mixer.Accounts.User.Senders.SendPasswordResetEmail # these configurations will be the default in a future release password_reset_action_name :reset_password_with_token request_password_reset_action_name :request_password_reset_token end end remember_me :remember_me magic_link do identity_field :email registration_enabled? true require_interaction? true sender Mixer.Accounts.User.Senders.SendMagicLinkEmail end api_key :api_key do api_key_relationship :valid_api_keys api_key_hash_attribute :api_key_hash end end end postgres do table "users" repo Mixer.Repo end typescript do type_name "users" end actions do defaults [:read] 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 update :change_password do # Use this action to allow users to change their password by providing # their current password and a new password. require_atomic? false accept [] argument :current_password, :string, sensitive?: true, allow_nil?: false argument :password, :string, sensitive?: true, allow_nil?: false, constraints: [min_length: 8] argument :password_confirmation, :string, sensitive?: true, allow_nil?: false validate confirm(:password, :password_confirmation) validate {AshAuthentication.Strategy.Password.PasswordValidation, strategy_name: :password, password_argument: :current_password} change {AshAuthentication.Strategy.Password.HashPasswordChange, strategy_name: :password} end read :sign_in_with_password do description "Attempt to sign in using a email and password." get? true argument :email, :ci_string do description "The email to use for retrieving the user." allow_nil? false end argument :password, :string do description "The password to check for the matching user." allow_nil? false sensitive? true end # validates the provided email and password and generates a token prepare AshAuthentication.Strategy.Password.SignInPreparation metadata :token, :string do description "A JWT that can be used to authenticate the user." allow_nil? false end end read :sign_in_with_token do # In the generated sign in components, we validate the # email and password directly in the LiveView # and generate a short-lived token that can be used to sign in over # a standard controller action, exchanging it for a standard token. # This action performs that exchange. If you do not use the generated # liveviews, you may remove this action, and set # `sign_in_tokens_enabled? false` in the password strategy. description "Attempt to sign in using a short-lived sign in token." get? true argument :token, :string do description "The short-lived sign in token." allow_nil? false sensitive? true end # validates the provided sign in token and generates a token prepare AshAuthentication.Strategy.Password.SignInWithTokenPreparation metadata :token, :string do description "A JWT that can be used to authenticate the user." allow_nil? false end end create :register_with_password do description "Register a new user with a email and password." argument :email, :ci_string do allow_nil? false end argument :password, :string do description "The proposed password for the user, in plain text." allow_nil? false constraints min_length: 8 sensitive? true end argument :password_confirmation, :string do description "The proposed password for the user (again), in plain text." allow_nil? false sensitive? true end argument :username, :string do description "The desired username for the user (letters, numbers, underscores)." allow_nil? false constraints match: ~r/^[a-zA-Z0-9_]+$/, min_length: 3, max_length: 30 end # Sets the email from the argument change set_attribute(:email, arg(:email)) # Sets the username from the argument change set_attribute(:username, arg(:username)) # Hashes the provided password change AshAuthentication.Strategy.Password.HashPasswordChange # Generates an authentication token for the user change AshAuthentication.GenerateTokenChange # validates that the password matches the confirmation validate AshAuthentication.Strategy.Password.PasswordConfirmationValidation metadata :token, :string do description "A JWT that can be used to authenticate the user." allow_nil? false end end action :request_password_reset_token do description "Send password reset instructions to a user if they exist." argument :email, :ci_string do allow_nil? false end # creates a reset token and invokes the relevant senders run {AshAuthentication.Strategy.Password.RequestPasswordReset, action: :get_by_email} end read :get_by_email do description "Looks up a user by their email" get_by :email end update :update_profile do description "Update the user's public profile (username, display name)." accept [:username, :display_name] require_atomic? false end update :update_avatar do description "Store the S3 key of the user's processed avatar thumbnail." accept [:avatar_url] require_atomic? false end update :reset_password_with_token do argument :reset_token, :string do allow_nil? false sensitive? true end argument :password, :string do description "The proposed password for the user, in plain text." allow_nil? false constraints min_length: 8 sensitive? true end argument :password_confirmation, :string do description "The proposed password for the user (again), in plain text." allow_nil? false sensitive? true end # validates the provided reset token validate AshAuthentication.Strategy.Password.ResetTokenValidation # validates that the password matches the confirmation validate AshAuthentication.Strategy.Password.PasswordConfirmationValidation # Hashes the provided password change AshAuthentication.Strategy.Password.HashPasswordChange # Generates an authentication token for the user change AshAuthentication.GenerateTokenChange end create :sign_in_with_magic_link do description "Sign in or register a user with magic link." argument :token, :string do description "The token from the magic link that was sent to the user" allow_nil? false end argument :remember_me, :boolean do description "Whether to generate a remember me token" allow_nil? true end argument :username, :string do description "Username chosen during first-time magic link registration." allow_nil? true constraints match: ~r/^[a-zA-Z0-9_]+$/, min_length: 3, max_length: 30 end upsert? true upsert_identity :unique_email upsert_fields [:email] # Uses the information from the token to create or sign in the user change AshAuthentication.Strategy.MagicLink.SignInChange change {AshAuthentication.Strategy.RememberMe.MaybeGenerateTokenChange, strategy_name: :remember_me} # Set username on new users (or existing users who haven't set one yet) change fn changeset, _ctx -> case Ash.Changeset.get_argument(changeset, :username) do nil -> changeset username -> # Set the attribute directly so the unique_username identity's # eager_check_with fires during Form.validate, surfacing "already # taken" errors in the UI before the action is submitted. changeset = Ash.Changeset.change_attribute(changeset, :username, username) # Also update via after_action to handle existing users who have no # username yet: for upserts, only upsert_fields are applied to the # conflicting row, so change_attribute above won't touch them. Ash.Changeset.after_action(changeset, fn _cs, user -> if is_nil(user.username) do user |> Ash.Changeset.for_update(:update_profile, %{username: username},authorize?: false) |> Ash.update() |> case do {:ok, updated} -> {:ok, updated} {:error, error} -> {:error, error} end else {:ok, user} end end) end end metadata :token, :string do allow_nil? false end end action :request_magic_link do argument :email, :ci_string do allow_nil? false end run AshAuthentication.Strategy.MagicLink.Request end read :sign_in_with_api_key do argument :api_key, :string, allow_nil?: false prepare AshAuthentication.Strategy.ApiKey.SignInPreparation end end policies do bypass AshAuthentication.Checks.AshAuthenticationInteraction do authorize_if always() end policy action_type(:read) do authorize_if always() end policy action(:update_profile) do authorize_if expr(id == ^actor(:id)) end policy action(:update_avatar) do authorize_if expr(id == ^actor(:id)) end end attributes do uuid_primary_key :id attribute :email, :ci_string do allow_nil? false public? true end attribute :hashed_password, :string do sensitive? true end attribute :confirmed_at, :utc_datetime_usec attribute :username, :string do public? true constraints match: ~r/^[a-zA-Z0-9_]+$/, min_length: 3, max_length: 30 end attribute :display_name, :string do public? true constraints max_length: 50 end attribute :avatar_url, :string do public? true end end relationships do has_many :valid_api_keys, Mixer.Accounts.ApiKey do filter expr(valid) end has_many :tweet_likes, Mixer.Posts.TweetLike has_many :tweets, Mixer.Posts.Tweet has_many :followers, Mixer.Accounts.Follow do destination_attribute :following_id end has_many :following, Mixer.Accounts.Follow do destination_attribute :follower_id end end aggregates do count :follower_count, :followers do public? true end count :following_count, :following do public? true end exists :am_i_following, :followers do public? true filter expr(follower_id == ^actor(:id)) end first :my_follow_id, :followers, :id do public? true filter expr(follower_id == ^actor(:id)) end end identities do identity :unique_email, [:email] identity :unique_username, [:username] do eager_check_with Mixer.Accounts message "is already taken" nils_distinct? true end end end