14.4 KiB · text 5af55d9
defmodule GitGudWeb.UserLive.Settings do
use GitGudWeb, :live_view
on_mount {GitGudWeb.UserAuth, :require_sudo_mode}
alias GitGud.Accounts
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="text-center">
<.header>
Account Settings
<:subtitle>Manage your account email address and password settings</:subtitle>
</.header>
</div>
<section class="space-y-3 text-left">
<h2 class="font-semibold">Pinned repositories</h2>
<p class="text-xs opacity-70">
Up to {GitGud.Profiles.max_pins()}. Drag the cards to reorder.
</p>
<%= if @pins == [] do %>
<p class="text-sm opacity-60">No pins yet.</p>
<% else %>
<GitGudWeb.PinnedReposGrid.grid pins={@pins} reorderable?={true} />
<ul class="text-xs space-y-1 mt-1">
<li :for={pin <- @pins} class="flex items-center gap-2">
<span class="font-mono opacity-70">
{pin.repository |> GitGud.Repositories.Storage.repo_handle()}/{pin.repository.name}
</span>
<button
type="button"
phx-click="unpin"
phx-value-repo-id={pin.repository.id}
class="btn btn-ghost btn-xs"
title="Unpin"
>
<.icon name="hero-x-mark-micro" class="size-3" /> Unpin
</button>
</li>
</ul>
<% end %>
<%= if @pinnable_repos != [] and length(@pins) < GitGud.Profiles.max_pins() do %>
<form phx-submit="pin" class="flex items-center gap-2">
<select name="repo_id" class="select select-sm select-bordered" required>
<option value="">— pick a repo to pin —</option>
<option :for={r <- @pinnable_repos} value={r.id}>
{GitGud.Repositories.Storage.repo_handle(r)}/{r.name}
</option>
</select>
<button type="submit" class="btn btn-sm btn-primary">Pin</button>
</form>
<% end %>
</section>
<div class="divider" />
<section class="space-y-3">
<h2 class="font-semibold text-left">Public profile</h2>
<p class="text-xs opacity-70 text-left">
Shown on <code phx-no-curly-interpolation>/u/{@current_scope.user.handle}</code>
and anywhere else you show up. All fields optional.
</p>
<.form
for={@profile_form}
id="profile_form"
phx-submit="update_profile"
class="space-y-2 text-left"
>
<.input field={@profile_form[:display_name]} label="Display name" />
<.input field={@profile_form[:bio]} type="textarea" rows="3" label="Bio" />
<.input field={@profile_form[:url]} label="URL" />
<.input field={@profile_form[:location]} label="Location" />
<.input field={@profile_form[:company]} label="Company" />
<.input field={@profile_form[:public_email]} label="Public email" type="email" />
<.input field={@profile_form[:timezone]} label="Timezone (e.g. America/New_York)" />
<.input
field={@profile_form[:socials_text]}
type="textarea"
rows="3"
label="Social links — one per line: platform | url"
/>
<.button variant="primary" phx-disable-with="Saving...">Save profile</.button>
</.form>
</section>
<div class="divider" />
<.form for={@email_form} id="email_form" phx-submit="update_email" phx-change="validate_email">
<.input
field={@email_form[:email]}
type="email"
label="Email"
autocomplete="username"
spellcheck="false"
required
/>
<.button variant="primary" phx-disable-with="Changing...">Change Email</.button>
</.form>
<div class="divider" />
<section class="space-y-2 text-left">
<h2 class="font-semibold">Invites</h2>
<p class="text-xs opacity-70">
Generate single-use invite links to bring new people onto the
instance.
</p>
<.link navigate={~p"/users/settings/invites"} class="btn btn-sm">
Manage invites
</.link>
</section>
<div class="divider" />
<section class="space-y-2">
<h2 class="font-semibold">Two-factor authentication</h2>
<p class="text-xs opacity-70">
<%= if GitGud.Accounts.TwoFactor.enabled?(@current_scope.user) do %>
Enabled. Manage your authenticator + recovery codes:
<% else %>
Off. Add a second factor to protect your account:
<% end %>
</p>
<.link navigate={~p"/users/settings/two-factor"} class="btn btn-sm btn-primary">
<%= if GitGud.Accounts.TwoFactor.enabled?(@current_scope.user) do %>
Manage two-factor
<% else %>
Enable two-factor
<% end %>
</.link>
</section>
<div class="divider" />
<.form for={@theme_form} id="theme_form" phx-submit="update_theme">
<.input
field={@theme_form[:editor_theme]}
type="select"
label="Editor theme"
prompt="— default —"
options={GitGud.Themes.Editor.options()}
/>
<p class="text-xs opacity-60 -mt-2 mb-3">
Applied to syntax-highlighted source files and fenced code in markdown.
</p>
<.button variant="primary" phx-disable-with="Saving...">Save Theme</.button>
</.form>
<div class="divider" />
<.form
for={@password_form}
id="password_form"
action={~p"/users/update-password"}
method="post"
phx-change="validate_password"
phx-submit="update_password"
phx-trigger-action={@trigger_submit}
>
<input
name={@password_form[:email].name}
type="hidden"
id="hidden_user_email"
spellcheck="false"
value={@current_email}
/>
<.input
field={@password_form[:password]}
type="password"
label="New password"
autocomplete="new-password"
spellcheck="false"
required
/>
<.input
field={@password_form[:password_confirmation]}
type="password"
label="Confirm new password"
autocomplete="new-password"
spellcheck="false"
/>
<.button variant="primary" phx-disable-with="Saving...">
Save Password
</.button>
</.form>
</Layouts.app>
"""
end
@impl true
def mount(%{"token" => token}, _session, socket) do
socket =
case Accounts.update_user_email(socket.assigns.current_scope.user, token) do
{:ok, _user} ->
put_flash(socket, :info, "Email changed successfully.")
{:error, _} ->
put_flash(socket, :error, "Email change link is invalid or it has expired.")
end
{:ok, push_navigate(socket, to: ~p"/users/settings")}
end
def mount(_params, _session, socket) do
user = socket.assigns.current_scope.user
email_changeset = Accounts.change_user_email(user, %{}, validate_unique: false)
password_changeset = Accounts.change_user_password(user, %{}, hash_password: false)
theme_changeset = Ecto.Changeset.change(user, %{})
socket =
socket
|> assign(:current_email, user.email)
|> assign(:email_form, to_form(email_changeset))
|> assign(:password_form, to_form(password_changeset))
|> assign(:theme_form, to_form(theme_changeset, as: "user"))
|> assign_profile_form(user)
|> load_pins()
|> assign(:trigger_submit, false)
{:ok, socket}
end
defp load_pins(socket) do
user = socket.assigns.current_scope.user
pins = GitGud.Profiles.list_pins(user)
pinned_repo_ids = MapSet.new(pins, & &1.repository_id)
pinnable =
GitGud.Repositories.list_pinnable_for_user(user)
|> Enum.reject(&MapSet.member?(pinned_repo_ids, &1.id))
socket
|> assign(:pins, pins)
|> assign(:pinnable_repos, pinnable)
end
defp assign_profile_form(socket, user) do
base =
Map.take(user, [:display_name, :bio, :url, :location, :company, :public_email, :timezone])
|> Map.put(:socials_text, socials_to_text(user.socials))
assign(
socket,
:profile_form,
to_form(Ecto.Changeset.change(user, %{}) |> Map.put(:params, stringify(base)), as: "user")
)
end
defp stringify(map) do
Map.new(map, fn {k, v} -> {Atom.to_string(k), v || ""} end)
end
defp socials_to_text(%{"items" => items}) when is_list(items) do
items
|> Enum.map(fn
%{"platform" => p, "url" => u} -> "#{p} | #{u}"
_ -> nil
end)
|> Enum.reject(&is_nil/1)
|> Enum.join("\n")
end
defp socials_to_text(_), do: ""
defp parse_socials(text) when is_binary(text) do
items =
text
|> String.split(~r/\r?\n/, trim: true)
|> Enum.map(fn line ->
case String.split(line, "|", parts: 2) do
[platform, url] ->
p = String.trim(platform)
u = String.trim(url)
if p != "" and u != "", do: %{"platform" => p, "url" => u, "handle" => ""}, else: nil
_ ->
nil
end
end)
|> Enum.reject(&is_nil/1)
%{"items" => items}
end
defp parse_socials(_), do: %{"items" => []}
def handle_event("pin", %{"repo_id" => id}, socket) do
user = socket.assigns.current_scope.user
with {repo_id, ""} <- Integer.parse(id),
%{} = repo <- GitGud.Repo.get(GitGud.Repositories.Repository, repo_id),
true <- pinnable_by_user?(repo, user) do
case GitGud.Profiles.pin(user, repo) do
{:ok, _} ->
{:noreply, socket |> load_pins() |> put_flash(:info, "Pinned.")}
{:error, :max_reached} ->
{:noreply,
put_flash(socket, :error, "You can pin at most #{GitGud.Profiles.max_pins()} repos.")}
{:error, _cs} ->
{:noreply, put_flash(socket, :error, "Couldn't pin that.")}
end
else
_ -> {:noreply, put_flash(socket, :error, "You can only pin your own or your orgs' repos.")}
end
end
def handle_event("unpin", %{"repo-id" => id}, socket) do
user = socket.assigns.current_scope.user
case Integer.parse(id) do
{repo_id, ""} ->
repo = %GitGud.Repositories.Repository{id: repo_id}
_ = GitGud.Profiles.unpin(user, repo)
{:noreply, load_pins(socket)}
_ ->
{:noreply, socket}
end
end
def handle_event("reorder_pins", %{"order" => order}, socket) do
user = socket.assigns.current_scope.user
_ = GitGud.Profiles.reorder(user, order)
{:noreply, load_pins(socket)}
end
def handle_event("update_profile", %{"user" => params}, socket) do
user = socket.assigns.current_scope.user
attrs =
params
|> Map.take([
"display_name",
"bio",
"url",
"location",
"company",
"public_email",
"timezone"
])
|> Map.put("socials", parse_socials(params["socials_text"]))
case Accounts.update_user_profile(user, attrs) do
{:ok, updated} ->
{:noreply,
socket
|> assign_profile_form(updated)
|> put_flash(:info, "Profile updated.")}
{:error, changeset} ->
{:noreply, assign(socket, :profile_form, to_form(changeset, as: "user"))}
end
end
def handle_event("update_theme", %{"user" => %{"editor_theme" => theme}}, socket) do
user = socket.assigns.current_scope.user
normalized = if theme == "" or theme == nil, do: nil, else: theme
if is_nil(normalized) or GitGud.Themes.Editor.valid?(normalized) do
{:ok, updated} =
user
|> GitGud.Accounts.User.editor_theme_changeset(normalized)
|> GitGud.Repo.update()
{:noreply,
socket
|> assign(:theme_form, to_form(Ecto.Changeset.change(updated), as: "user"))
|> put_flash(:info, "Editor theme updated.")
|> push_navigate(to: ~p"/users/settings")}
else
{:noreply, put_flash(socket, :error, "Unknown theme.")}
end
end
@impl true
def handle_event("validate_email", params, socket) do
%{"user" => user_params} = params
email_form =
socket.assigns.current_scope.user
|> Accounts.change_user_email(user_params, validate_unique: false)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, email_form: email_form)}
end
def handle_event("update_email", params, socket) do
%{"user" => user_params} = params
user = socket.assigns.current_scope.user
true = Accounts.sudo_mode?(user)
case Accounts.change_user_email(user, user_params) do
%{valid?: true} = changeset ->
Accounts.deliver_user_update_email_instructions(
Ecto.Changeset.apply_action!(changeset, :insert),
user.email,
&url(~p"/users/settings/confirm-email/#{&1}")
)
info = "A link to confirm your email change has been sent to the new address."
{:noreply, socket |> put_flash(:info, info)}
changeset ->
{:noreply, assign(socket, :email_form, to_form(changeset, action: :insert))}
end
end
def handle_event("validate_password", params, socket) do
%{"user" => user_params} = params
password_form =
socket.assigns.current_scope.user
|> Accounts.change_user_password(user_params, hash_password: false)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, password_form: password_form)}
end
def handle_event("update_password", params, socket) do
%{"user" => user_params} = params
user = socket.assigns.current_scope.user
true = Accounts.sudo_mode?(user)
case Accounts.change_user_password(user, user_params) do
%{valid?: true} = changeset ->
{:noreply, assign(socket, trigger_submit: true, password_form: to_form(changeset))}
changeset ->
{:noreply, assign(socket, password_form: to_form(changeset, action: :insert))}
end
end
defp pinnable_by_user?(repo, user) do
cond do
repo.owner_id == user.id and is_nil(repo.organization_id) -> true
is_integer(repo.organization_id) -> member_of_org?(user.id, repo.organization_id)
true -> false
end
end
defp member_of_org?(uid, oid) do
import Ecto.Query, only: [from: 2]
GitGud.Repo.exists?(
from m in GitGud.Organizations.Membership,
where: m.user_id == ^uid and m.organization_id == ^oid
)
end
end