fix: translation of login page
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Simon 2026-03-13 14:11:54 +01:00
parent 086ecdcb1b
commit 99a8d64344
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
18 changed files with 487 additions and 200 deletions

View file

@ -3,52 +3,57 @@ defmodule MvWeb.AuthOverrides do
UI customizations for AshAuthentication Phoenix components.
## Overrides
- `SignIn` - Restricts form width to prevent full-width display
- `Banner` - Replaces default logo with "Mitgliederverwaltung" text
- `HorizontalRule` - Translates "or" text to German
- `SignIn` - Restricts form width and hides the library banner (title is rendered in SignInLive)
- `Banner` - Replaces default logo with text for reset/confirm pages
- `Flash` - Hides library flash (we use flash_group in root layout)
## Documentation
For complete reference on available overrides, see:
https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
"""
use AshAuthentication.Phoenix.Overrides
use Gettext, backend: MvWeb.Gettext
# 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
# Avoid full-width for the Sign In Form
# Avoid full-width for the Sign In Form.
# Banner is hidden because SignInLive renders its own locale-aware title.
override AshAuthentication.Phoenix.Components.SignIn do
set :root_class, "md:min-w-md"
set :show_banner, false
end
# Replace banner logo with text (no image in light or dark so link has discernible text)
# Replace banner logo with text for reset/confirm pages (no image so link has discernible text).
override AshAuthentication.Phoenix.Components.Banner do
set :text, "Mitgliederverwaltung"
set :image_url, nil
set :dark_image_url, nil
end
# Translate the "or" in the horizontal rule (between password form and SSO).
# Uses auth domain so it respects the current locale (e.g. "oder" in German).
override AshAuthentication.Phoenix.Components.HorizontalRule do
set :text, dgettext("auth", "or")
end
# Hide AshAuthentication's Flash component since we use flash_group in root layout
# This prevents duplicate flash messages
# Hide AshAuthentication's Flash component since we use flash_group in root layout.
# This prevents duplicate flash messages.
override AshAuthentication.Phoenix.Components.Flash do
set :message_class_info, "hidden"
set :message_class_error, "hidden"
end
end
defmodule MvWeb.AuthOverridesDE do
@moduledoc """
German locale-specific overrides for AshAuthentication Phoenix components.
Prepended to the overrides list in SignInLive when the locale is "de".
Provides runtime-static German text for components that do not use
the `_gettext` mechanism (e.g. HorizontalRule renders its text directly),
and for submit buttons whose disable_text bypasses the POT extraction pipeline.
"""
use AshAuthentication.Phoenix.Overrides
# HorizontalRule renders text without `_gettext`, so we need a static German string.
override AshAuthentication.Phoenix.Components.HorizontalRule do
set :text, "oder"
end
# Registering ... disable-text is passed through _gettext but "Registering ..."
# has no dgettext source reference, so we supply the German string directly.
override AshAuthentication.Phoenix.Components.Password.RegisterForm do
set :disable_button_text, "Registrieren..."
end
end

View file

@ -13,6 +13,54 @@ defmodule MvWeb.Layouts do
embed_templates "layouts/*"
@doc """
Renders the public (unauthenticated) page layout: header with logo + "Mitgliederverwaltung" left,
club name centered, language selector right; plus main content and flash group. Use for sign-in, join, and join-confirm pages so they
share the same chrome without the sidebar or authenticated layout logic.
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
slot :inner_block, required: true
def public_page(assigns) do
club_name =
case Mv.Membership.get_settings() do
{:ok, s} -> s.club_name || "Mitgliederverwaltung"
_ -> "Mitgliederverwaltung"
end
assigns = assign(assigns, :club_name, club_name)
~H"""
<header class="relative flex items-center justify-between p-4 border-b border-base-300 bg-base-100">
<div class="flex items-center gap-3 shrink-0 min-w-0 max-w-[45%]">
<img src={~p"/images/mila.svg"} alt="Mila Logo" class="size-8 shrink-0" />
<span class="text-lg font-bold truncate">Mitgliederverwaltung</span>
</div>
<span class="absolute left-1/2 -translate-x-1/2 text-lg font-bold text-center max-w-[50%] truncate">
{@club_name}
</span>
<form method="post" action={~p"/set_locale"} class="shrink-0">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<select
name="locale"
onchange="this.form.submit()"
class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
aria-label={gettext("Select language")}
>
<option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
</select>
</form>
</header>
<main class="px-4 py-8 sm:px-6">
<div class="mx-auto max-full space-y-4">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
"""
end
@doc """
Renders the app layout. Can be used with or without a current_user.
When current_user is present, it will show the navigation bar.
@ -99,10 +147,13 @@ defmodule MvWeb.Layouts do
</div>
</div>
<% else %>
<!-- Unauthenticated: simple header (logo, club name, language selector; same classes as sidebar header) -->
<header class="flex items-center gap-3 p-4 border-b border-base-300 bg-base-100">
<img src={~p"/images/mila.svg"} alt="Mila Logo" class="size-8 shrink-0" />
<span class="menu-label text-lg font-bold truncate flex-1">
<!-- Unauthenticated: Option 3 header (logo + app name left, club name center, language selector right) -->
<header class="relative flex items-center justify-between p-4 border-b border-base-300 bg-base-100">
<div class="flex items-center gap-3 shrink-0 min-w-0 max-w-[45%]">
<img src={~p"/images/mila.svg"} alt="Mila Logo" class="size-8 shrink-0" />
<span class="menu-label text-lg font-bold truncate">Mitgliederverwaltung</span>
</div>
<span class="absolute left-1/2 -translate-x-1/2 text-lg font-bold text-center max-w-[50%] truncate">
{@club_name}
</span>
<form method="post" action={~p"/set_locale"} class="shrink-0">
@ -113,8 +164,8 @@ defmodule MvWeb.Layouts do
class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
aria-label={gettext("Select language")}
>
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
<option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
</select>
</form>
</header>

View file

@ -260,8 +260,8 @@ defmodule MvWeb.Layouts.Sidebar do
class="select select-sm w-full focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
aria-label={gettext("Select language")}
>
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
<option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
</select>
</form>
<!-- Theme Toggle (immer sichtbar) -->

View file

@ -2,8 +2,9 @@ defmodule MvWeb.JoinConfirmController do
@moduledoc """
Handles GET /confirm_join/:token for the public join flow (double opt-in).
Calls a configurable callback (default Mv.Membership) so tests can stub the
dependency. Public route; no authentication required.
Renders a full HTML page with public header and hero layout (success, expired,
or invalid). Calls a configurable callback (default Mv.Membership) so tests can
stub the dependency. Public route; no authentication required.
"""
use MvWeb, :controller
@ -26,20 +27,36 @@ defmodule MvWeb.JoinConfirmController do
defp success_response(conn) do
conn
|> put_resp_content_type("text/html")
|> send_resp(200, gettext("Thank you, we have received your request."))
|> assign_confirm_assigns(:success)
|> put_view(MvWeb.JoinConfirmHTML)
|> render("confirm.html")
end
defp expired_response(conn) do
conn
|> put_resp_content_type("text/html")
|> send_resp(200, gettext("This link has expired. Please submit the form again."))
|> assign_confirm_assigns(:expired)
|> put_view(MvWeb.JoinConfirmHTML)
|> render("confirm.html")
end
defp invalid_response(conn) do
conn
|> put_resp_content_type("text/html")
|> put_status(404)
|> send_resp(404, gettext("Invalid or expired link."))
|> assign_confirm_assigns(:invalid)
|> put_view(MvWeb.JoinConfirmHTML)
|> render("confirm.html")
end
defp assign_confirm_assigns(conn, result) do
club_name =
case Mv.Membership.get_settings() do
{:ok, settings} -> settings.club_name || "Mitgliederverwaltung"
_ -> "Mitgliederverwaltung"
end
conn
|> assign(:result, result)
|> assign(:club_name, club_name)
|> assign(:csrf_token, Plug.CSRFProtection.get_csrf_token())
end
end

View file

@ -0,0 +1,9 @@
defmodule MvWeb.JoinConfirmHTML do
@moduledoc """
Renders join confirmation result pages (success, expired, invalid) with
public header and hero layout. Used by JoinConfirmController.
"""
use MvWeb, :html
embed_templates "join_confirm_html/*"
end

View file

@ -0,0 +1,65 @@
<%!-- Public header (same structure as Layouts.app unauthenticated branch) --%>
<header class="flex items-center gap-3 p-4 border-b border-base-300 bg-base-100">
<img src={~p"/images/mila.svg"} alt="Mila Logo" class="size-8 shrink-0" />
<span class="menu-label text-lg font-bold truncate flex-1">
{@club_name}
</span>
<form method="post" action={~p"/set_locale"} class="shrink-0">
<input type="hidden" name="_csrf_token" value={@csrf_token} />
<select
name="locale"
onchange="this.form.submit()"
class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
aria-label={gettext("Select language")}
>
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
</select>
</form>
</header>
<main class="px-4 py-8 sm:px-6">
<div class="max-w-4xl mx-auto">
<div class="hero min-h-[60vh] bg-base-200 rounded-lg">
<div class="hero-content flex-col items-start text-left">
<div class="max-w-md">
<%= case @result do %>
<% :success -> %>
<h1 class="text-3xl font-bold">
{gettext("Thank you")}
</h1>
<p class="py-4 text-base-content/80">
{gettext("Thank you, we have received your request.")}
</p>
<p class="text-sm text-base-content/70">
{gettext("You will receive an email once your application has been reviewed.")}
</p>
<a href={~p"/join"} class="btn btn-primary mt-4">
{gettext("Back to join form")}
</a>
<% :expired -> %>
<h1 class="text-3xl font-bold">
{gettext("Link expired")}
</h1>
<p class="py-4 text-base-content/80">
{gettext("This link has expired. Please submit the form again.")}
</p>
<a href={~p"/join"} class="btn btn-primary mt-4">
{gettext("Submit new request")}
</a>
<% :invalid -> %>
<h1 class="text-3xl font-bold text-error">
{gettext("Invalid or expired link")}
</h1>
<p class="py-4 text-base-content/80">
{gettext("Invalid or expired link.")}
</p>
<a href={~p"/join"} class="btn btn-primary mt-4">
{gettext("Go to join form")}
</a>
<% end %>
</div>
</div>
</div>
</div>
</main>

View file

@ -1,28 +1,42 @@
defmodule MvWeb.SignInLive do
@moduledoc """
Custom sign-in page with language selector and conditional Single Sign-On button.
Custom sign-in page with public header and hero layout (same as Join/Join Confirm).
- Renders a language selector (same pattern as LinkOidcAccountLive).
- Wraps the default AshAuthentication SignIn component in a container with
`data-oidc-configured` so that CSS can hide the SSO button when OIDC is not configured.
Uses Layouts.public_page (no sidebar, no app-layout hooks). Wraps the AshAuthentication
SignIn component in a hero section. Container has data-oidc-configured so CSS can hide
the SSO button when OIDC is not configured.
Keeps `use Phoenix.LiveView` (not MvWeb :live_view) so AshAuthentication's sign_in_route
live_session on_mount chain is not mixed with LiveHelpers hooks.
## Locale overrides
`MvWeb.AuthOverridesDE` is prepended to the overrides list when the locale is "de",
providing static German strings for components that do not use `_gettext` internally
(e.g. HorizontalRule renders its `:text` override directly).
"""
use Phoenix.LiveView
use Gettext, backend: MvWeb.Gettext
alias AshAuthentication.Phoenix.Components
alias Mv.Config
alias MvWeb.{AuthOverridesDE, Layouts}
@impl true
def mount(_params, session, socket) do
overrides =
session
|> Map.get("overrides", [AshAuthentication.Phoenix.Overrides.Default])
# Locale: same fallback as LiveUserAuth so config :default_locale (e.g. "en" in test) is respected
locale =
session["locale"] || Application.get_env(:mv, :default_locale, "de")
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
# Set both backend-specific and global locale so Gettext.get_locale/0 and
# Gettext.get_locale/1 both return the correct value (important for the
# language-selector `selected` attribute in Layouts.public_page).
Gettext.put_locale(MvWeb.Gettext, locale)
Gettext.put_locale(locale)
# Prepend DE-specific overrides when locale is German so that components
# without _gettext support (e.g. HorizontalRule) still render in German.
base_overrides = Map.get(session, "overrides", [AshAuthentication.Phoenix.Overrides.Default])
locale_overrides = if locale == "de", do: [AuthOverridesDE], else: []
overrides = locale_overrides ++ base_overrides
socket =
socket
@ -36,10 +50,9 @@ defmodule MvWeb.SignInLive do
|> assign(:context, session["context"] || %{})
|> assign(:auth_routes_prefix, session["auth_routes_prefix"])
|> assign(:gettext_fn, session["gettext_fn"])
|> assign(:live_action, :sign_in)
|> assign_new(:live_action, fn -> :sign_in end)
|> assign(:oidc_configured, Config.oidc_configured?())
|> assign(:oidc_only, Config.oidc_only?())
|> assign(:root_class, "grid h-screen place-items-center bg-base-100")
|> assign(:sign_in_id, "sign-in")
|> assign(:locale, locale)
@ -54,50 +67,43 @@ defmodule MvWeb.SignInLive do
@impl true
def render(assigns) do
~H"""
<main
id="sign-in-page"
role="main"
class={@root_class}
data-oidc-configured={to_string(@oidc_configured)}
data-oidc-only={to_string(@oidc_only)}
data-locale={@locale}
>
<h1 class="sr-only">{dgettext("auth", "Sign in")}</h1>
<%!-- Language selector --%>
<nav
aria-label={dgettext("auth", "Language selection")}
class="absolute top-4 right-4 flex justify-end z-10"
>
<form method="post" action="/set_locale" class="text-sm">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<select
name="locale"
onchange="this.form.submit()"
class="select select-sm select-bordered bg-base-100"
aria-label={dgettext("auth", "Select language")}
>
<option value="de" selected={@locale == "de"}>Deutsch</option>
<option value="en" selected={@locale == "en"}>English</option>
</select>
</form>
</nav>
<.live_component
module={Components.SignIn}
otp_app={@otp_app}
live_action={@live_action}
path={@path}
auth_routes_prefix={@auth_routes_prefix}
resources={@resources}
reset_path={@reset_path}
register_path={@register_path}
id={@sign_in_id}
overrides={@overrides}
current_tenant={@current_tenant}
context={@context}
gettext_fn={@gettext_fn}
/>
</main>
<Layouts.public_page flash={@flash}>
<div class="max-w-4xl mx-auto">
<div
class="hero min-h-[60vh] bg-base-200 rounded-lg"
id="sign-in-page"
role="main"
data-oidc-configured={to_string(@oidc_configured)}
data-oidc-only={to_string(@oidc_only)}
data-locale={@locale}
>
<div class="hero-content flex-col items-start text-left">
<div class="w-full max-w-md">
<h1 class="text-xl font-semibold leading-8">
{if @live_action == :register,
do: dgettext("auth", "Register"),
else: dgettext("auth", "Sign in")}
</h1>
<.live_component
module={Components.SignIn}
otp_app={@otp_app}
live_action={@live_action}
path={@path}
auth_routes_prefix={@auth_routes_prefix}
resources={@resources}
reset_path={@reset_path}
register_path={@register_path}
id={@sign_in_id}
overrides={@overrides}
current_tenant={@current_tenant}
context={@context}
gettext_fn={@gettext_fn}
/>
</div>
</div>
</div>
</div>
</Layouts.public_page>
"""
end
end

View file

@ -33,91 +33,97 @@ defmodule MvWeb.JoinLive do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<div class="max-w-xl mx-auto mt-8 space-y-6">
<.header>
{gettext("Become a member")}
</.header>
<Layouts.public_page flash={@flash}>
<div class="max-w-4xl mx-auto">
<div class="hero min-h-[60vh] bg-base-200 rounded-lg">
<div class="hero-content flex-col items-start text-left">
<div class="max-w-xl w-full space-y-6">
<.header>
{gettext("Become a member")}
</.header>
<p class="text-base-content/80">
{gettext("Please enter your details for the membership application here.")}
</p>
<p class="text-base-content/80">
{gettext("Please enter your details for the membership application here.")}
</p>
<%= if @submitted do %>
<div data-testid="join-success-message" class="alert alert-success">
<p class="font-medium">
{gettext(
"We have saved your details. To complete your request, please click the link we sent to your email."
)}
</p>
</div>
<% else %>
<.form
for={@form}
id="join-form"
phx-submit="submit"
class="space-y-4"
>
<%= if @rate_limit_error do %>
<div class="alert alert-error">
{@rate_limit_error}
</div>
<% end %>
<%= if @submitted do %>
<div data-testid="join-success-message" class="alert alert-success">
<p class="font-medium">
{gettext(
"We have saved your details. To complete your request, please click the link we sent to your email."
)}
</p>
</div>
<% else %>
<.form
for={@form}
id="join-form"
phx-submit="submit"
class="space-y-4"
>
<%= if @rate_limit_error do %>
<div class="alert alert-error">
{@rate_limit_error}
</div>
<% end %>
<%= for field <- @join_fields do %>
<div>
<label for={"join-field-#{field.id}"} class="label">
<span class="label-text">{field.label}{if field.required, do: " *"}</span>
</label>
<input
type={input_type(field.id)}
name={field.id}
id={"join-field-#{field.id}"}
value={@form.params[field.id]}
required={field.required}
class="input input-bordered w-full"
/>
</div>
<% end %>
<%= for field <- @join_fields do %>
<div>
<label for={"join-field-#{field.id}"} class="label">
<span class="label-text">{field.label}{if field.required, do: " *"}</span>
</label>
<input
type={input_type(field.id)}
name={field.id}
id={"join-field-#{field.id}"}
value={@form.params[field.id]}
required={field.required}
class="input input-bordered w-full"
/>
</div>
<% end %>
<%!--
<%!--
Honeypot (best practice): legit field name "website", type="text", no inline CSS,
hidden via class in app.css (off-screen + 1px). tabindex=-1, autocomplete=off,
aria-hidden so screen readers skip. If filled silent failure (same success UI).
--%>
<div class="join-form-helper" aria-hidden="true">
<label for="join-website" class="sr-only">{gettext("Website")}</label>
<input
type="text"
name={@honeypot_field}
id="join-website"
tabindex="-1"
autocomplete="off"
class="join-form-helper-input"
/>
<div class="join-form-helper" aria-hidden="true">
<label for="join-website" class="sr-only">{gettext("Website")}</label>
<input
type="text"
name={@honeypot_field}
id="join-website"
tabindex="-1"
autocomplete="off"
class="join-form-helper-input"
/>
</div>
<p class="text-sm text-base-content/70">
{gettext(
"By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
)}
</p>
<p class="text-xs text-base-content/60">
{gettext(
"Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary."
)}
</p>
<div>
<button type="submit" class="btn btn-primary">
{gettext("Submit request")}
</button>
</div>
</.form>
<% end %>
</div>
<p class="text-sm text-base-content/70">
{gettext(
"By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
)}
</p>
<p class="text-xs text-base-content/60">
{gettext(
"Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary."
)}
</p>
<div>
<button type="submit" class="btn btn-primary">
{gettext("Submit request")}
</button>
</div>
</.form>
<% end %>
</div>
</div>
</div>
</Layouts.app>
</Layouts.public_page>
"""
end