some ai generated code from claude that does not work
This commit is contained in:
@@ -4,6 +4,7 @@ defmodule Mixer.Accounts do
|
||||
typescript_rpc do
|
||||
resource Mixer.Accounts.User do
|
||||
rpc_action :read_user, :read
|
||||
rpc_action :update_profile, :update_profile
|
||||
end
|
||||
|
||||
resource Mixer.Accounts.Follow do
|
||||
|
||||
33
lib/mixer/accounts/avatar_uploader.ex
Normal file
33
lib/mixer/accounts/avatar_uploader.ex
Normal file
@@ -0,0 +1,33 @@
|
||||
defmodule Mixer.Accounts.AvatarUploader do
|
||||
use Waffle.Definition
|
||||
|
||||
@versions [:original, :thumb]
|
||||
@extensions ~w(.jpg .jpeg .png .gif .webp)
|
||||
|
||||
def validate({file, _scope}) do
|
||||
ext = file.file_name |> Path.extname() |> String.downcase()
|
||||
if ext in @extensions, do: :ok, else: {:error, "unsupported file type #{ext}"}
|
||||
end
|
||||
|
||||
# Resize to a 256×256 square (centre-crop) and convert to WebP for efficiency
|
||||
def transform(:thumb, _) do
|
||||
{:convert, "-strip -thumbnail 256x256^ -gravity center -extent 256x256 -format webp", :webp}
|
||||
end
|
||||
|
||||
# Store both versions under avatars/:user_id/
|
||||
def storage_dir(_version, {_file, scope}), do: "avatars/#{scope.user_id}"
|
||||
|
||||
def filename(:original, {file, _scope}) do
|
||||
Path.basename(file.file_name, Path.extname(file.file_name))
|
||||
end
|
||||
|
||||
def filename(:thumb, _), do: "thumb"
|
||||
|
||||
def s3_object_headers(:thumb, _), do: [content_type: "image/webp"]
|
||||
|
||||
def s3_object_headers(_version, {file, _scope}) do
|
||||
[content_type: MIME.from_path(file.file_name)]
|
||||
end
|
||||
|
||||
def acl(_version, _), do: :public_read
|
||||
end
|
||||
@@ -177,9 +177,21 @@ defmodule Mixer.Accounts.User do
|
||||
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
|
||||
|
||||
@@ -211,6 +223,18 @@ defmodule Mixer.Accounts.User do
|
||||
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
|
||||
@@ -256,6 +280,15 @@ defmodule Mixer.Accounts.User do
|
||||
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]
|
||||
@@ -266,6 +299,32 @@ defmodule Mixer.Accounts.User do
|
||||
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 ->
|
||||
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}
|
||||
# Don't fail the sign-in just because username set failed
|
||||
{:error, _} -> {:ok, user}
|
||||
end
|
||||
else
|
||||
{:ok, user}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
metadata :token, :string do
|
||||
allow_nil? false
|
||||
end
|
||||
@@ -293,6 +352,14 @@ defmodule Mixer.Accounts.User do
|
||||
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
|
||||
@@ -308,6 +375,23 @@ defmodule Mixer.Accounts.User do
|
||||
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
|
||||
@@ -350,5 +434,6 @@ defmodule Mixer.Accounts.User do
|
||||
|
||||
identities do
|
||||
identity :unique_email, [:email]
|
||||
identity :unique_username, [:username], nils_distinct?: true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -254,6 +254,18 @@ defmodule Mixer.Posts.Tweet do
|
||||
calculate :user_email, :string, expr(user.email) do
|
||||
public? true
|
||||
end
|
||||
|
||||
calculate :user_username, :string, expr(user.username) do
|
||||
public? true
|
||||
end
|
||||
|
||||
calculate :user_display_name, :string, expr(user.display_name) do
|
||||
public? true
|
||||
end
|
||||
|
||||
calculate :user_avatar_url, :string, expr(user.avatar_url) do
|
||||
public? true
|
||||
end
|
||||
end
|
||||
|
||||
aggregates do
|
||||
|
||||
@@ -15,4 +15,9 @@ defmodule MixerWeb.AuthOverrides do
|
||||
set :text, "⬡ Mixer"
|
||||
set :text_class, "text-3xl font-bold tracking-tight"
|
||||
end
|
||||
|
||||
# Inject the username field into the password registration form
|
||||
override AshAuthentication.Phoenix.Components.Password do
|
||||
set :register_extra_component, &MixerWeb.AuthComponents.username_register_field/1
|
||||
end
|
||||
end
|
||||
|
||||
51
lib/mixer_web/components/auth_components.ex
Normal file
51
lib/mixer_web/components/auth_components.ex
Normal file
@@ -0,0 +1,51 @@
|
||||
defmodule MixerWeb.AuthComponents do
|
||||
@moduledoc """
|
||||
Extra components injected into AshAuthentication.Phoenix forms.
|
||||
"""
|
||||
|
||||
use Phoenix.Component
|
||||
|
||||
@doc """
|
||||
Renders a username input field inside the password registration form.
|
||||
|
||||
Receives `form` (an `AshPhoenix.Form`) as an assign via the
|
||||
`register_extra_component` override.
|
||||
"""
|
||||
def username_register_field(assigns) do
|
||||
field = assigns.form[:username]
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:field_id, field.id)
|
||||
|> assign(:field_name, field.name)
|
||||
|> assign(:field_value, field.value || "")
|
||||
|> assign(:field_errors, field.errors)
|
||||
|
||||
~H"""
|
||||
<div class="mt-2 mb-2">
|
||||
<label for={@field_id} class="block text-sm font-medium text-base-content mb-1">
|
||||
Username
|
||||
</label>
|
||||
<div class="flex">
|
||||
<span class="input rounded-r-none border-r-0 text-base-content/50 select-none">@</span>
|
||||
<input
|
||||
type="text"
|
||||
id={@field_id}
|
||||
name={@field_name}
|
||||
value={@field_value}
|
||||
class={"input w-full rounded-l-none #{if @field_errors != [], do: "input-error", else: ""}"}
|
||||
placeholder="your_handle"
|
||||
autocomplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p :if={@field_errors != []} class="mt-1 text-xs text-error">
|
||||
{@field_errors |> List.first() |> elem(0)}
|
||||
</p>
|
||||
<p :if={@field_errors == []} class="mt-1 text-xs text-base-content/50">
|
||||
3–30 characters · letters, numbers, underscores
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,11 @@
|
||||
id="app"
|
||||
data-current-user-id={if @current_user, do: @current_user.id, else: ""}
|
||||
data-current-user-email={if @current_user, do: @current_user.email, else: ""}
|
||||
data-current-user-username={if @current_user, do: @current_user.username || "", else: ""}
|
||||
data-current-user-display-name={
|
||||
if @current_user, do: @current_user.display_name || "", else: ""
|
||||
}
|
||||
data-current-user-avatar-url={if @current_user, do: @current_user.avatar_url || "", else: ""}
|
||||
data-asset-host={@media_host}
|
||||
data-page={@page}
|
||||
data-tweet-id={@tweet_id || ""}
|
||||
|
||||
@@ -2,6 +2,7 @@ defmodule MixerWeb.UploadController do
|
||||
use MixerWeb, :controller
|
||||
|
||||
alias Mixer.Posts.MediaUploader
|
||||
alias Mixer.Accounts.AvatarUploader
|
||||
|
||||
def create(conn, %{"file" => %Plug.Upload{} = upload}) do
|
||||
actor = conn.assigns[:current_user]
|
||||
@@ -46,4 +47,48 @@ defmodule MixerWeb.UploadController do
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "no file provided"})
|
||||
end
|
||||
|
||||
# ── Avatar upload ──────────────────────────────────────────────────────────
|
||||
|
||||
def upload_avatar(conn, %{"file" => %Plug.Upload{} = upload}) do
|
||||
actor = conn.assigns[:current_user]
|
||||
|
||||
unless actor do
|
||||
conn
|
||||
|> put_status(:unauthorized)
|
||||
|> json(%{error: "authentication required"})
|
||||
else
|
||||
scope = %{user_id: actor.id}
|
||||
|
||||
case AvatarUploader.store({upload, scope}) do
|
||||
{:ok, _file_name} ->
|
||||
# The thumb is always stored as avatars/:user_id/thumb.webp
|
||||
thumb_key = "avatars/#{actor.id}/thumb.webp"
|
||||
|
||||
actor
|
||||
|> Ash.Changeset.for_update(:update_avatar, %{avatar_url: thumb_key}, actor: actor)
|
||||
|> Ash.update()
|
||||
|> case do
|
||||
{:ok, _user} ->
|
||||
json(conn, %{success: true, avatarUrl: thumb_key})
|
||||
|
||||
{:error, error} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{success: false, error: inspect(error)})
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{success: false, error: reason})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def upload_avatar(conn, _params) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "no file provided"})
|
||||
end
|
||||
end
|
||||
|
||||
184
lib/mixer_web/live/magic_sign_in_live.ex
Normal file
184
lib/mixer_web/live/magic_sign_in_live.ex
Normal file
@@ -0,0 +1,184 @@
|
||||
defmodule MixerWeb.MagicSignInLive do
|
||||
@moduledoc """
|
||||
Custom magic-link sign-in LiveView that collects a username for new users.
|
||||
|
||||
When a user clicks their magic link, this page is shown instead of the
|
||||
default auto-submit. If the user is brand new (no account) or has no
|
||||
username set yet, we ask them to choose one before completing sign-in.
|
||||
"""
|
||||
|
||||
use AshAuthentication.Phoenix.Overrides.Overridable,
|
||||
root_class: "CSS class for the root `div` element.",
|
||||
magic_sign_in_id: "Element ID for the `MagicSignIn` LiveComponent."
|
||||
|
||||
use AshAuthentication.Phoenix.Web, :live_view
|
||||
|
||||
alias AshAuthentication.Info
|
||||
alias AshPhoenix.Form
|
||||
alias Phoenix.LiveView.{Rendered, Socket}
|
||||
|
||||
import AshAuthentication.Phoenix.Components.Helpers, only: [auth_path: 5]
|
||||
import PhoenixHTMLHelpers.Form, only: [hidden_input: 3, submit: 2]
|
||||
import Slug
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def mount(params, session, socket) do
|
||||
overrides =
|
||||
session
|
||||
|> Map.get("overrides", [AshAuthentication.Phoenix.Overrides.Default])
|
||||
|
||||
resource = session["resource"]
|
||||
strategy_name = session["strategy"]
|
||||
token = params["token"] || params["magic_link"]
|
||||
|
||||
strategy = Info.strategy!(resource, strategy_name)
|
||||
subject_name = Info.authentication_subject_name!(resource)
|
||||
domain = Info.authentication_domain!(resource)
|
||||
|
||||
# Determine whether this user needs to pick a username
|
||||
needs_username? = needs_username?(token, resource)
|
||||
|
||||
form =
|
||||
resource
|
||||
|> Form.for_action(strategy.sign_in_action_name,
|
||||
domain: domain,
|
||||
as: subject_name |> to_string(),
|
||||
id: "#{subject_name}-#{strategy_name}-sign-in-form" |> slugify(),
|
||||
context: %{strategy: strategy, private: %{ash_authentication?: true}}
|
||||
)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(overrides: overrides)
|
||||
|> assign(:token, token)
|
||||
|> assign(:strategy, strategy)
|
||||
|> assign(:subject_name, subject_name)
|
||||
|> assign(:resource, resource)
|
||||
|> assign(:needs_username?, needs_username?)
|
||||
|> assign(:form, form)
|
||||
|> assign(:trigger_action, false)
|
||||
|> assign(:current_tenant, session["tenant"])
|
||||
|> assign(:auth_routes_prefix, session["auth_routes_prefix"])
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec handle_params(map, String.t(), Socket.t()) :: {:noreply, Socket.t()}
|
||||
def handle_params(_params, _uri, socket), do: {:noreply, socket}
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec render(Socket.assigns()) :: Rendered.t()
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class={override_for(@overrides, :root_class)}>
|
||||
<.live_component
|
||||
module={AshAuthentication.Phoenix.Components.Banner}
|
||||
id="magic-sign-in-banner"
|
||||
overrides={@overrides}
|
||||
/>
|
||||
|
||||
<div style="max-width: 400px; margin: 0 auto; padding: 1rem;">
|
||||
<.form
|
||||
:let={form}
|
||||
for={@form}
|
||||
phx-submit="submit"
|
||||
phx-trigger-action={@trigger_action}
|
||||
action={
|
||||
auth_path(
|
||||
@socket,
|
||||
@subject_name,
|
||||
@auth_routes_prefix,
|
||||
@strategy,
|
||||
:sign_in
|
||||
)
|
||||
}
|
||||
method="POST"
|
||||
>
|
||||
{hidden_input(form, :token, value: @token)}
|
||||
|
||||
<%!-- Username field — only shown for new or username-less users --%>
|
||||
<div :if={@needs_username?} class="mt-2 mb-4">
|
||||
<label
|
||||
for={form[:username].id}
|
||||
class="block text-sm font-medium text-base-content mb-1"
|
||||
>
|
||||
Choose a username
|
||||
</label>
|
||||
<div class="flex">
|
||||
<span class="input rounded-r-none border-r-0 text-base-content/50 select-none">
|
||||
@
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
id={form[:username].id}
|
||||
name={form[:username].name}
|
||||
value={form[:username].value || ""}
|
||||
class={"input w-full rounded-l-none #{if form[:username].errors != [], do: "input-error", else: ""}"}
|
||||
placeholder="your_handle"
|
||||
autocomplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
:if={form[:username].errors != []}
|
||||
class="mt-1 text-xs text-error"
|
||||
>
|
||||
{form[:username].errors |> List.first() |> elem(0)}
|
||||
</p>
|
||||
<p :if={form[:username].errors == []} class="mt-1 text-xs text-base-content/50">
|
||||
3–30 characters · letters, numbers, underscores
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{submit("Sign in",
|
||||
class: "btn btn-primary w-full mt-2",
|
||||
phx_disable_with: "Signing in…"
|
||||
)}
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec handle_event(String.t(), map(), Socket.t()) :: {:noreply, Socket.t()}
|
||||
def handle_event("submit", params, socket) do
|
||||
subject_name =
|
||||
socket.assigns.subject_name
|
||||
|> to_string()
|
||||
|> slugify()
|
||||
|
||||
form_params = Map.get(params, subject_name, %{})
|
||||
|
||||
form = Form.validate(socket.assigns.form, form_params)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:form, form)
|
||||
|> assign(:trigger_action, form.valid?)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# Returns true if the user is new or has no username set yet.
|
||||
defp needs_username?(nil, _resource), do: true
|
||||
|
||||
defp needs_username?(token, resource) do
|
||||
with {:ok, claims} <- AshAuthentication.Jwt.peek(token),
|
||||
subject when is_binary(subject) <- Map.get(claims, "sub"),
|
||||
{:ok, user} <- AshAuthentication.subject_to_user(subject, resource) do
|
||||
is_nil(user.username)
|
||||
else
|
||||
_ ->
|
||||
# Unknown / new user — ask for username to be safe
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -47,6 +47,7 @@ defmodule MixerWeb.Router do
|
||||
post "/rpc/run", AshTypescriptRpcController, :run
|
||||
post "/rpc/validate", AshTypescriptRpcController, :validate
|
||||
post "/upload", UploadController, :create
|
||||
post "/upload/avatar", UploadController, :upload_avatar
|
||||
auth_routes AuthController, Mixer.Accounts.User, path: "/auth"
|
||||
sign_out_route AuthController
|
||||
|
||||
@@ -74,6 +75,7 @@ defmodule MixerWeb.Router do
|
||||
|
||||
# Remove this if you do not use the magic link strategy.
|
||||
magic_sign_in_route(Mixer.Accounts.User, :magic_link,
|
||||
live_view: MixerWeb.MagicSignInLive,
|
||||
auth_routes_prefix: "/auth",
|
||||
overrides: [MixerWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI]
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user