Compare commits

..

13 commits

Author SHA1 Message Date
ae179703db
Move custom fields to global admin settings
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-12-03 14:33:31 +01:00
422cf37a1e Merge pull request 'Fix UI issues' (#242) from ui-fixes into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #242
Reviewed-by: simon <s.thiessen@local-it.org>
2025-12-03 14:30:13 +01:00
a10d42f1ed Merge pull request 'Add file_envs for secrets and allow passing database url via separate envs' (#246) from add-file-envs into main
Some checks reported errors
continuous-integration/drone/push Build was killed
Reviewed-on: #246
Reviewed-by: rafael <rafael@noreply.git.local-it.org>
2025-12-03 14:29:32 +01:00
d1bab1288c
Merge remote-tracking branch 'origin/main' into add-file-envs
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-12-03 14:29:04 +01:00
1623b63207
fix: resolve review comments
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-12-03 14:27:22 +01:00
e6c5a58c65
Show dates in european format
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-12-03 14:20:14 +01:00
ee414c9440
Hide OIDC ID and ID columns for users 2025-12-03 14:20:14 +01:00
366d4c104a
Prevent tables from growing the page horizontally 2025-12-03 14:20:14 +01:00
ce15b8f59b
fix: mailto formatting
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 12:54:49 +01:00
d8384098b4
chore: update prod-compose to use file-envs for secrets
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 12:38:24 +01:00
ee094eec2f
feat: add file env support for secrets 2025-12-03 12:36:13 +01:00
eedd24b93c
Truncate long entries in tables to prevent height changes 2025-12-02 16:33:15 +01:00
06ba50f05d
Fix translation "Bearbeite" -> "Bearbeiten" 2025-12-02 16:32:55 +01:00
20 changed files with 305 additions and 133 deletions

3
.gitignore vendored
View file

@ -41,3 +41,6 @@ npm-debug.log
.env .env
.elixir_ls/ .elixir_ls/
# Docker secrets directory (generated by `just init-secrets`)
/secrets/

View file

@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- CopyToClipboard JavaScript hook with fallback for older browsers - CopyToClipboard JavaScript hook with fallback for older browsers
- Button shows count of visible selected members (respects search/filter) - Button shows count of visible selected members (respects search/filter)
- German/English translations - German/English translations
- Docker secrets support via `_FILE` environment variables for all sensitive configuration (SECRET_KEY_BASE, TOKEN_SIGNING_SECRET, OIDC_CLIENT_SECRET, DATABASE_URL, DATABASE_PASSWORD)
### Fixed ### Fixed
- Email validation false positive when linking user and member with identical emails (#168 Problem #4) - Email validation false positive when linking user and member with identical emails (#168 Problem #4)

View file

@ -91,3 +91,26 @@ remove-gettext-conflicts:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
find priv/gettext -type f -exec sed -i '/^<<<<<<< HEAD$/d; /^=======$/d; /^>>>>>>>/d' {} \; find priv/gettext -type f -exec sed -i '/^<<<<<<< HEAD$/d; /^=======$/d; /^>>>>>>>/d' {} \;
# Production environment commands
# ================================
# Initialize secrets directory with generated secrets (only if not exists)
init-prod-secrets:
#!/usr/bin/env bash
set -euo pipefail
if [ -d "secrets" ]; then
echo "Secrets directory already exists. Skipping generation."
exit 0
fi
echo "Creating secrets directory and generating secrets..."
mkdir -p secrets
mix phx.gen.secret > secrets/secret_key_base.txt
mix phx.gen.secret > secrets/token_signing_secret.txt
openssl rand -base64 32 | tr -d '\n' > secrets/db_password.txt
touch secrets/oidc_client_secret.txt
echo "Secrets generated in ./secrets/"
# Start production environment with Docker Compose
start-prod: init-prod-secrets
docker compose -f docker-compose.prod.yml up -d

View file

@ -217,6 +217,13 @@ For testing the production Docker build locally:
# OIDC_BASE_URL=http://localhost:8080/auth/v1 # OIDC_BASE_URL=http://localhost:8080/auth/v1
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback # OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
# OIDC_CLIENT_SECRET=<from-rauthy-client> # OIDC_CLIENT_SECRET=<from-rauthy-client>
# Alternative: Use _FILE variables for Docker secrets (takes priority over regular vars):
# SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base
# TOKEN_SIGNING_SECRET_FILE=/run/secrets/token_signing_secret
# OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
# DATABASE_URL_FILE=/run/secrets/database_url
# DATABASE_PASSWORD_FILE=/run/secrets/database_password
``` ```
3. **Start development environment** (for Rauthy): 3. **Start development environment** (for Rauthy):
@ -250,7 +257,7 @@ For actual production deployment:
- Set `OIDC_BASE_URL` to your production OIDC provider - Set `OIDC_BASE_URL` to your production OIDC provider
- Configure proper Docker networks - Configure proper Docker networks
3. **Set up SSL/TLS** (e.g., via reverse proxy like Nginx/Traefik) 3. **Set up SSL/TLS** (e.g., via reverse proxy like Nginx/Traefik)
4. **Use secure secrets management** (environment variables, Docker secrets, vault) 4. **Use secure secrets management** — All sensitive environment variables support a `_FILE` suffix for Docker secrets (e.g., `SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base`). See `docker-compose.prod.yml` for an example setup with Docker secrets.
5. **Configure database backups** 5. **Configure database backups**

View file

@ -7,6 +7,75 @@ import Config
# any compile-time configuration in here, as it won't be applied. # any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration. # The block below contains prod specific runtime configuration.
# Helper function to read environment variables with Docker secrets support.
# Supports the _FILE suffix pattern: if VAR_FILE is set, reads the value from
# that file path. Otherwise falls back to VAR directly.
# VAR_FILE takes priority and must contain the full absolute path to the secret file.
get_env_or_file = fn var_name, default ->
file_var = "#{var_name}_FILE"
case System.get_env(file_var) do
nil ->
System.get_env(var_name, default)
file_path ->
case File.read(file_path) do
{:ok, content} ->
String.trim_trailing(content)
{:error, reason} ->
raise """
Failed to read secret from file specified in #{file_var}="#{file_path}".
Error: #{inspect(reason)}
"""
end
end
end
# Same as get_env_or_file but raises if the value is not set
get_env_or_file! = fn var_name, error_message ->
case get_env_or_file.(var_name, nil) do
nil -> raise error_message
value -> value
end
end
# Build database URL from individual components or use DATABASE_URL directly.
# Supports both approaches:
# 1. DATABASE_URL (or DATABASE_URL_FILE) - full connection URL
# 2. Separate vars: DATABASE_HOST, DATABASE_USER, DATABASE_PASSWORD (or _FILE), DATABASE_NAME, DATABASE_PORT
build_database_url = fn ->
case get_env_or_file.("DATABASE_URL", nil) do
nil ->
# Build URL from separate components
host =
System.get_env("DATABASE_HOST") ||
raise "DATABASE_HOST is required when DATABASE_URL is not set"
user =
System.get_env("DATABASE_USER") ||
raise "DATABASE_USER is required when DATABASE_URL is not set"
password =
get_env_or_file!.("DATABASE_PASSWORD", """
DATABASE_PASSWORD or DATABASE_PASSWORD_FILE is required when DATABASE_URL is not set.
""")
database =
System.get_env("DATABASE_NAME") ||
raise "DATABASE_NAME is required when DATABASE_URL is not set"
port = System.get_env("DATABASE_PORT", "5432")
# URL-encode the password to handle special characters
encoded_password = URI.encode_www_form(password)
"ecto://#{user}:#{encoded_password}@#{host}:#{port}/#{database}"
url ->
url
end
end
# ## Using releases # ## Using releases
# #
# If you use `mix release`, you need to explicitly enable the server # If you use `mix release`, you need to explicitly enable the server
@ -21,12 +90,7 @@ if System.get_env("PHX_SERVER") do
end end
if config_env() == :prod do if config_env() == :prod do
database_url = database_url = build_database_url.()
System.get_env("DATABASE_URL") ||
raise """
environment variable DATABASE_URL is missing.
For example: ecto://USER:PASS@HOST/DATABASE
"""
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
@ -41,12 +105,12 @@ if config_env() == :prod do
# want to use a different value for prod and you most likely don't want # want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment # to check this value into version control, so we use an environment
# variable instead. # variable instead.
# Supports SECRET_KEY_BASE or SECRET_KEY_BASE_FILE for Docker secrets.
secret_key_base = secret_key_base =
System.get_env("SECRET_KEY_BASE") || get_env_or_file!.("SECRET_KEY_BASE", """
raise """ environment variable SECRET_KEY_BASE (or SECRET_KEY_BASE_FILE) is missing.
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret You can generate one by calling: mix phx.gen.secret
""" """)
host = System.get_env("PHX_HOST") || raise "Please define the PHX_HOST environment variable." host = System.get_env("PHX_HOST") || raise "Please define the PHX_HOST environment variable."
port = String.to_integer(System.get_env("PORT") || "4000") port = String.to_integer(System.get_env("PORT") || "4000")
@ -54,32 +118,47 @@ if config_env() == :prod do
config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
# Rauthy OIDC configuration # Rauthy OIDC configuration
# Supports OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE for Docker secrets.
# OIDC_CLIENT_SECRET is required only if OIDC is being used (indicated by explicit OIDC env vars).
oidc_base_url = System.get_env("OIDC_BASE_URL")
oidc_client_id = System.get_env("OIDC_CLIENT_ID")
oidc_in_use = not is_nil(oidc_base_url) or not is_nil(oidc_client_id)
client_secret =
if oidc_in_use do
get_env_or_file!.("OIDC_CLIENT_SECRET", """
environment variable OIDC_CLIENT_SECRET (or OIDC_CLIENT_SECRET_FILE) is missing.
This is required when OIDC authentication is configured (OIDC_BASE_URL or OIDC_CLIENT_ID is set).
""")
else
get_env_or_file.("OIDC_CLIENT_SECRET", nil)
end
config :mv, :rauthy, config :mv, :rauthy,
client_id: System.get_env("OIDC_CLIENT_ID") || "mv", client_id: oidc_client_id || "mv",
base_url: System.get_env("OIDC_BASE_URL") || "http://localhost:8080/auth/v1", base_url: oidc_base_url || "http://localhost:8080/auth/v1",
client_secret: System.get_env("OIDC_CLIENT_SECRET"), client_secret: client_secret,
redirect_uri: redirect_uri:
System.get_env("OIDC_REDIRECT_URI") || "http://#{host}:#{port}/auth/user/rauthy/callback" System.get_env("OIDC_REDIRECT_URI") || "http://#{host}:#{port}/auth/user/rauthy/callback"
# Token signing secret from environment variable # Token signing secret from environment variable
# This overrides the placeholder value set in prod.exs # This overrides the placeholder value set in prod.exs
# Supports TOKEN_SIGNING_SECRET or TOKEN_SIGNING_SECRET_FILE for Docker secrets.
token_signing_secret = token_signing_secret =
System.get_env("TOKEN_SIGNING_SECRET") || get_env_or_file!.("TOKEN_SIGNING_SECRET", """
raise """ environment variable TOKEN_SIGNING_SECRET (or TOKEN_SIGNING_SECRET_FILE) is missing.
environment variable TOKEN_SIGNING_SECRET is missing.
You can generate one by calling: mix phx.gen.secret You can generate one by calling: mix phx.gen.secret
""" """)
config :mv, :token_signing_secret, token_signing_secret config :mv, :token_signing_secret, token_signing_secret
config :mv, MvWeb.Endpoint, config :mv, MvWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"], url: [host: host, port: 443, scheme: "https"],
http: [ http: [
# Enable IPv6 and bind on all interfaces. # Bind on all IPv4 interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. # Use {0, 0, 0, 0, 0, 0, 0, 0} for IPv6, or {127, 0, 0, 1} for localhost only.
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
# for details about using IPv6 vs IPv4 and loopback vs public addresses. ip: {0, 0, 0, 0},
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: port port: port
], ],
secret_key_base: secret_key_base, secret_key_base: secret_key_base,

View file

@ -2,21 +2,32 @@ services:
app: app:
image: git.local-it.org/local-it/mitgliederverwaltung:latest image: git.local-it.org/local-it/mitgliederverwaltung:latest
container_name: mv-prod-app container_name: mv-prod-app
# Use host network for local testing to access localhost:8080 (Rauthy) ports:
# In real production, remove this and use external OIDC provider - "4001:4001"
network_mode: host
environment: environment:
DATABASE_URL: "ecto://postgres:postgres@localhost:5001/mv_prod" # Database configuration using separate variables
SECRET_KEY_BASE: "${SECRET_KEY_BASE}" # Use Docker service name for internal networking
TOKEN_SIGNING_SECRET: "${TOKEN_SIGNING_SECRET}" DATABASE_HOST: "db-prod"
PHX_HOST: "${PHX_HOST}" DATABASE_PORT: "5432"
DATABASE_USER: "postgres"
DATABASE_NAME: "mv_prod"
DATABASE_PASSWORD_FILE: "/run/secrets/db_password"
# Phoenix secrets via Docker secrets
SECRET_KEY_BASE_FILE: "/run/secrets/secret_key_base"
TOKEN_SIGNING_SECRET_FILE: "/run/secrets/token_signing_secret"
PHX_HOST: "${PHX_HOST:-localhost}"
PORT: "4001" PORT: "4001"
PHX_SERVER: "true" PHX_SERVER: "true"
# Rauthy OIDC config - uses localhost because of host network mode # Rauthy OIDC config - use host.docker.internal to reach host services
OIDC_CLIENT_ID: "mv" OIDC_CLIENT_ID: "mv"
OIDC_BASE_URL: "http://localhost:8080/auth/v1" OIDC_BASE_URL: "http://host.docker.internal:8080/auth/v1"
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}" OIDC_CLIENT_SECRET_FILE: "/run/secrets/oidc_client_secret"
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback" OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback"
secrets:
- db_password
- secret_key_base
- token_signing_secret
- oidc_client_secret
depends_on: depends_on:
- db-prod - db-prod
restart: unless-stopped restart: unless-stopped
@ -26,13 +37,25 @@ services:
container_name: mv-prod-db container_name: mv-prod-db
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD_FILE: /run/secrets/db_password
POSTGRES_DB: mv_prod POSTGRES_DB: mv_prod
secrets:
- db_password
volumes: volumes:
- postgres_data_prod:/var/lib/postgresql/data - postgres_data_prod:/var/lib/postgresql/data
ports: ports:
- "5001:5432" - "5001:5432"
restart: unless-stopped restart: unless-stopped
secrets:
db_password:
file: ./secrets/db_password.txt
secret_key_base:
file: ./secrets/secret_key_base.txt
token_signing_secret:
file: ./secrets/token_signing_secret.txt
oidc_client_secret:
file: ./secrets/oidc_client_secret.txt
volumes: volumes:
postgres_data_prod: postgres_data_prod:

View file

@ -79,7 +79,7 @@ defmodule MvWeb.CoreComponents do
<p>{msg}</p> <p>{msg}</p>
</div> </div>
<div class="flex-1" /> <div class="flex-1" />
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}> <button type="button" class="self-start cursor-pointer group" aria-label={gettext("close")}>
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" /> <.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
</button> </button>
</div> </div>
@ -368,6 +368,7 @@ defmodule MvWeb.CoreComponents do
end end
~H""" ~H"""
<div class="overflow-auto">
<table class="table table-zebra"> <table class="table table-zebra">
<thead> <thead>
<tr> <tr>
@ -392,14 +393,14 @@ defmodule MvWeb.CoreComponents do
<td <td
:for={col <- @col} :for={col <- @col}
phx-click={@row_click && @row_click.(row)} phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"} class={["max-w-xs truncate", @row_click && "hover:cursor-pointer"]}
> >
{render_slot(col, @row_item.(row))} {render_slot(col, @row_item.(row))}
</td> </td>
<td <td
:for={dyn_col <- @dynamic_cols} :for={dyn_col <- @dynamic_cols}
phx-click={@row_click && @row_click.(row)} phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"} class={["max-w-xs truncate", @row_click && "hover:cursor-pointer"]}
> >
{if dyn_col[:render] do {if dyn_col[:render] do
rendered = dyn_col[:render].(@row_item.(row)) rendered = dyn_col[:render].(@row_item.(row))
@ -423,6 +424,7 @@ defmodule MvWeb.CoreComponents do
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
""" """
end end

View file

@ -0,0 +1,27 @@
defmodule MvWeb.Helpers.DateFormatter do
@moduledoc """
Centralized date formatting helper for the application.
Formats dates in European format (dd.mm.yyyy).
"""
use Gettext, backend: MvWeb.Gettext
@doc """
Formats a Date struct to European format (dd.mm.yyyy).
## Examples
iex> MvWeb.Helpers.DateFormatter.format_date(~D[2024-03-15])
"15.03.2024"
iex> MvWeb.Helpers.DateFormatter.format_date(nil)
""
"""
def format_date(%Date{} = date) do
Calendar.strftime(date, "%d.%m.%Y")
end
def format_date(nil), do: ""
def format_date(_), do: "Invalid date"
end

View file

@ -19,7 +19,7 @@ defmodule MvWeb.Components.SortHeaderComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="tooltip" data-tip={aria_sort(@field, @sort_field, @sort_order)}> <div class="tooltip tooltip-bottom" data-tip={aria_sort(@field, @sort_field, @sort_order)}>
<button <button
type="button" type="button"
aria-label={aria_sort(@field, @sort_field, @sort_order)} aria-label={aria_sort(@field, @sort_field, @sort_order)}

View file

@ -32,6 +32,7 @@ defmodule MvWeb.MemberLive.Index do
alias Mv.Membership alias Mv.Membership
alias MvWeb.MemberLive.Index.Formatter alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.Helpers.DateFormatter
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>") # Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
@custom_field_prefix "custom_field_" @custom_field_prefix "custom_field_"
@ -937,4 +938,7 @@ defmodule MvWeb.MemberLive.Index do
Map.get(visibility_config, Atom.to_string(field), true) Map.get(visibility_config, Atom.to_string(field), true)
end) end)
end end
# Public helper function to format dates for use in templates
def format_date(date), do: DateFormatter.format_date(date)
end end

View file

@ -224,7 +224,7 @@
""" """
} }
> >
{member.join_date} {MvWeb.MemberLive.Index.format_date(member.join_date)}
</:col> </:col>
<:col :let={member} label={gettext("Paid")}> <:col :let={member} label={gettext("Paid")}>
<span class={[ <span class={[

View file

@ -6,6 +6,7 @@ defmodule MvWeb.MemberLive.Index.Formatter do
formats them appropriately for display in the UI. formats them appropriately for display in the UI.
""" """
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
alias MvWeb.Helpers.DateFormatter
@doc """ @doc """
Formats a custom field value for display. Formats a custom field value for display.
@ -61,11 +62,11 @@ defmodule MvWeb.MemberLive.Index.Formatter do
defp format_value_by_type(value, :boolean, _) when value == false, do: gettext("No") defp format_value_by_type(value, :boolean, _) when value == false, do: gettext("No")
defp format_value_by_type(value, :boolean, _), do: to_string(value) defp format_value_by_type(value, :boolean, _), do: to_string(value)
defp format_value_by_type(%Date{} = date, :date, _), do: Date.to_string(date) defp format_value_by_type(%Date{} = date, :date, _), do: DateFormatter.format_date(date)
defp format_value_by_type(value, :date, _) when is_binary(value) do defp format_value_by_type(value, :date, _) when is_binary(value) do
case Date.from_iso8601(value) do case Date.from_iso8601(value) do
{:ok, date} -> Date.to_string(date) {:ok, date} -> DateFormatter.format_date(date)
_ -> value _ -> value
end end
end end

View file

@ -23,6 +23,7 @@ defmodule MvWeb.MemberLive.Show do
""" """
use MvWeb, :live_view use MvWeb, :live_view
import Ash.Query import Ash.Query
alias MvWeb.Helpers.DateFormatter
@impl true @impl true
def render(assigns) do def render(assigns) do
@ -52,8 +53,8 @@ defmodule MvWeb.MemberLive.Show do
{if @member.paid, do: gettext("Yes"), else: gettext("No")} {if @member.paid, do: gettext("Yes"), else: gettext("No")}
</:item> </:item>
<:item title={gettext("Phone Number")}>{@member.phone_number}</:item> <:item title={gettext("Phone Number")}>{@member.phone_number}</:item>
<:item title={gettext("Join Date")}>{@member.join_date}</:item> <:item title={gettext("Join Date")}>{DateFormatter.format_date(@member.join_date)}</:item>
<:item title={gettext("Exit Date")}>{@member.exit_date}</:item> <:item title={gettext("Exit Date")}>{DateFormatter.format_date(@member.exit_date)}</:item>
<:item title={gettext("Notes")}>{@member.notes}</:item> <:item title={gettext("Notes")}>{@member.notes}</:item>
<:item title={gettext("City")}>{@member.city}</:item> <:item title={gettext("City")}>{@member.city}</:item>
<:item title={gettext("Street")}>{@member.street}</:item> <:item title={gettext("Street")}>{@member.street}</:item>
@ -81,10 +82,7 @@ defmodule MvWeb.MemberLive.Show do
# name # name
cfv.custom_field && cfv.custom_field.name, cfv.custom_field && cfv.custom_field.name,
# value # value
case cfv.value do format_custom_field_value(cfv)
%{value: v} -> v
v -> v
end
} }
end) end)
} /> } />
@ -114,4 +112,17 @@ defmodule MvWeb.MemberLive.Show do
defp page_title(:show), do: gettext("Show Member") defp page_title(:show), do: gettext("Show Member")
defp page_title(:edit), do: gettext("Edit Member") defp page_title(:edit), do: gettext("Edit Member")
defp format_custom_field_value(cfv) do
value =
case cfv.value do
%{value: v} -> v
v -> v
end
case value do
%Date{} = date -> DateFormatter.format_date(date)
other -> other
end
end
end end

View file

@ -42,7 +42,7 @@ defmodule MvWeb.UserLive.Form do
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle> <:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
</.header> </.header>
<.form for={@form} id="user-form" phx-change="validate" phx-submit="save"> <.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
<.input field={@form[:email]} label={gettext("Email")} required type="email" /> <.input field={@form[:email]} label={gettext("Email")} required type="email" />
<!-- Password Section --> <!-- Password Section -->
@ -61,7 +61,7 @@ defmodule MvWeb.UserLive.Form do
</label> </label>
<%= if @show_password_fields do %> <%= if @show_password_fields do %>
<div class="mt-4 space-y-4 p-4 bg-gray-50 rounded-lg"> <div class="p-4 mt-4 space-y-4 rounded-lg bg-gray-50">
<.input <.input
field={@form[:password]} field={@form[:password]}
label={gettext("Password")} label={gettext("Password")}
@ -83,7 +83,7 @@ defmodule MvWeb.UserLive.Form do
<div class="text-sm text-gray-600"> <div class="text-sm text-gray-600">
<p><strong>{gettext("Password requirements")}:</strong></p> <p><strong>{gettext("Password requirements")}:</strong></p>
<ul class="list-disc list-inside text-xs mt-1 space-y-1"> <ul class="mt-1 space-y-1 text-xs list-disc list-inside">
<li>{gettext("At least 8 characters")}</li> <li>{gettext("At least 8 characters")}</li>
<li>{gettext("Include both letters and numbers")}</li> <li>{gettext("Include both letters and numbers")}</li>
<li>{gettext("Consider using special characters")}</li> <li>{gettext("Consider using special characters")}</li>
@ -91,7 +91,7 @@ defmodule MvWeb.UserLive.Form do
</div> </div>
<%= if @user do %> <%= if @user do %>
<div class="mt-3 p-3 bg-orange-50 border border-orange-200 rounded"> <div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
<p class="text-sm text-orange-800"> <p class="text-sm text-orange-800">
<strong>{gettext("Admin Note")}:</strong> {gettext( <strong>{gettext("Admin Note")}:</strong> {gettext(
"As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system." "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
@ -102,7 +102,7 @@ defmodule MvWeb.UserLive.Form do
</div> </div>
<% else %> <% else %>
<%= if @user do %> <%= if @user do %>
<div class="mt-4 p-4 bg-blue-50 rounded-lg"> <div class="p-4 mt-4 rounded-lg bg-blue-50">
<p class="text-sm text-blue-800"> <p class="text-sm text-blue-800">
<strong>{gettext("Note")}:</strong> {gettext( <strong>{gettext("Note")}:</strong> {gettext(
"Check 'Change Password' above to set a new password for this user." "Check 'Change Password' above to set a new password for this user."
@ -110,7 +110,7 @@ defmodule MvWeb.UserLive.Form do
</p> </p>
</div> </div>
<% else %> <% else %>
<div class="mt-4 p-4 bg-yellow-50 rounded-lg"> <div class="p-4 mt-4 rounded-lg bg-yellow-50">
<p class="text-sm text-yellow-800"> <p class="text-sm text-yellow-800">
<strong>{gettext("Note")}:</strong> {gettext( <strong>{gettext("Note")}:</strong> {gettext(
"User will be created without a password. Check 'Set Password' to add one." "User will be created without a password. Check 'Set Password' to add one."
@ -123,11 +123,11 @@ defmodule MvWeb.UserLive.Form do
<!-- Member Linking Section --> <!-- Member Linking Section -->
<div class="mt-6"> <div class="mt-6">
<h2 class="text-base font-semibold mb-3">{gettext("Linked Member")}</h2> <h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
<%= if @user && @user.member && !@unlink_member do %> <%= if @user && @user.member && !@unlink_member do %>
<!-- Show linked member with unlink button --> <!-- Show linked member with unlink button -->
<div class="p-4 bg-green-50 border border-green-200 rounded-lg"> <div class="p-4 border border-green-200 rounded-lg bg-green-50">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="font-medium text-green-900"> <p class="font-medium text-green-900">
@ -147,7 +147,7 @@ defmodule MvWeb.UserLive.Form do
<% else %> <% else %>
<%= if @unlink_member do %> <%= if @unlink_member do %>
<!-- Show unlink pending message --> <!-- Show unlink pending message -->
<div class="p-4 bg-yellow-50 border border-yellow-200 rounded-lg"> <div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
<p class="text-sm text-yellow-800"> <p class="text-sm text-yellow-800">
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext( <strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
"Member will be unlinked when you save. Cannot select new member until saved." "Member will be unlinked when you save. Cannot select new member until saved."
@ -219,7 +219,7 @@ defmodule MvWeb.UserLive.Form do
</div> </div>
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %> <%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
<div class="p-3 bg-yellow-50 border border-yellow-200 rounded"> <div class="p-3 border border-yellow-200 rounded bg-yellow-50">
<p class="text-sm text-yellow-800"> <p class="text-sm text-yellow-800">
<strong>{gettext("Note")}:</strong> {gettext( <strong>{gettext("Note")}:</strong> {gettext(
"A member with this email already exists. To link with a different member, please change one of the email addresses first." "A member with this email already exists. To link with a different member, please change one of the email addresses first."
@ -231,12 +231,12 @@ defmodule MvWeb.UserLive.Form do
<%= if @selected_member_id && @selected_member_name do %> <%= if @selected_member_id && @selected_member_name do %>
<div <div
id="member-selected" id="member-selected"
class="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg" class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
> >
<p class="text-sm text-blue-800"> <p class="text-sm text-blue-800">
<strong>{gettext("Selected")}:</strong> {@selected_member_name} <strong>{gettext("Selected")}:</strong> {@selected_member_name}
</p> </p>
<p class="text-xs text-blue-600 mt-1"> <p class="mt-1 text-xs text-blue-600">
{gettext("Save to confirm linking.")} {gettext("Save to confirm linking.")}
</p> </p>
</div> </div>
@ -245,10 +245,12 @@ defmodule MvWeb.UserLive.Form do
<% end %> <% end %>
</div> </div>
<div class="mt-4">
<.button phx-disable-with={gettext("Saving...")} variant="primary"> <.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save User")} {gettext("Save User")}
</.button> </.button>
<.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}</.button> <.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}</.button>
</div>
</.form> </.form>
</Layouts.app> </Layouts.app>
""" """

View file

@ -49,7 +49,6 @@
> >
{user.email} {user.email}
</:col> </:col>
<:col :let={user} label={gettext("OIDC ID")}>{user.oidc_id}</:col>
<:col :let={user} label={gettext("Linked Member")}> <:col :let={user} label={gettext("Linked Member")}>
<%= if user.member do %> <%= if user.member do %>
{user.member.first_name} {user.member.last_name} {user.member.first_name} {user.member.last_name}

View file

@ -46,9 +46,7 @@ defmodule MvWeb.UserLive.Show do
</.header> </.header>
<.list> <.list>
<:item title={gettext("ID")}>{@user.id}</:item>
<:item title={gettext("Email")}>{@user.email}</:item> <:item title={gettext("Email")}>{@user.email}</:item>
<:item title={gettext("OIDC ID")}>{@user.oidc_id || gettext("Not set")}</:item>
<:item title={gettext("Password Authentication")}> <:item title={gettext("Password Authentication")}>
{if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")} {if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")}
</:item> </:item>
@ -56,13 +54,13 @@ defmodule MvWeb.UserLive.Show do
<%= if @user.member do %> <%= if @user.member do %>
<.link <.link
navigate={~p"/members/#{@user.member}"} navigate={~p"/members/#{@user.member}"}
class="text-blue-600 hover:text-blue-800 underline" class="text-blue-600 underline hover:text-blue-800"
> >
<.icon name="hero-users" class="h-4 w-4 inline mr-1" /> <.icon name="hero-users" class="inline w-4 h-4 mr-1" />
{@user.member.first_name} {@user.member.last_name} {@user.member.first_name} {@user.member.last_name}
</.link> </.link>
<% else %> <% else %>
<span class="text-gray-500 italic">{gettext("No member linked")}</span> <span class="italic text-gray-500">{gettext("No member linked")}</span>
<% end %> <% end %>
</:item> </:item>
</.list> </.list>

View file

@ -47,7 +47,7 @@ msgstr "Löschen"
#: lib/mv_web/live/user_live/index.html.heex:66 #: lib/mv_web/live/user_live/index.html.heex:66
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Edit" msgid "Edit"
msgstr "Bearbeite" msgstr "Bearbeiten"
#: lib/mv_web/live/member_live/show.ex:41 #: lib/mv_web/live/member_live/show.ex:41
#: lib/mv_web/live/member_live/show.ex:116 #: lib/mv_web/live/member_live/show.ex:116

View file

@ -90,8 +90,6 @@ defmodule MvWeb.ProfileNavigationTest do
# Verify we're on the correct profile page with OIDC specific information # Verify we're on the correct profile page with OIDC specific information
{:ok, _profile_view, html} = live(conn, "/users/#{user.id}") {:ok, _profile_view, html} = live(conn, "/users/#{user.id}")
assert html =~ to_string(user.email) assert html =~ to_string(user.email)
# OIDC ID should be visible
assert html =~ "oidc_123"
# Password auth should be disabled for OIDC users # Password auth should be disabled for OIDC users
assert html =~ "Not enabled" assert html =~ "Not enabled"
end end

View file

@ -231,8 +231,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members") {:ok, _view, html} = live(conn, "/members")
# Date should be displayed in readable format # Date should be displayed in European format (dd.mm.yyyy)
assert html =~ "1990" or html =~ "1990-05-15" or html =~ "15.05.1990" assert html =~ "15.05.1990"
end end
test "formats email custom field values correctly", %{conn: conn, member1: _member1} do test "formats email custom field values correctly", %{conn: conn, member1: _member1} do

View file

@ -33,8 +33,6 @@ defmodule MvWeb.UserLive.IndexTest do
assert html =~ "alice@example.com" assert html =~ "alice@example.com"
assert html =~ "bob@example.com" assert html =~ "bob@example.com"
assert html =~ "alice123"
assert html =~ "bob456"
end end
test "shows correct action links", %{conn: conn} do test "shows correct action links", %{conn: conn} do
@ -386,10 +384,6 @@ defmodule MvWeb.UserLive.IndexTest do
# Should still show the table structure # Should still show the table structure
assert html =~ "Email" assert html =~ "Email"
assert html =~ "OIDC ID"
# Should show the authenticated user at minimum
# Matches the generated email pattern oidc.user{unique_id}@example.com
assert html =~ "oidc.user"
end end
test "handles users with missing OIDC ID", %{conn: conn} do test "handles users with missing OIDC ID", %{conn: conn} do