Compare commits

..

133 commits

Author SHA1 Message Date
Renovate Bot
843ae1c8c8 Update renovate/renovate Docker tag to v43
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-25 00:03:27 +00:00
d614ad2219 Merge pull request 'Refinex CSV import and PDf export closes #299 and #433' (#446) from feat/299_plz into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #446
2026-02-24 16:32:31 +01:00
bfc078d5aa Merge branch 'main' into feat/299_plz
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-24 16:02:56 +01:00
c62b105518 test: updated
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 16:00:46 +01:00
d060486d0d Merge pull request 'OIDC-only sign-in, Vereinfacht connection test, locale defaults, and settings/docs cleanup' (#445) from feature/settings into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #445
2026-02-24 15:51:49 +01:00
eec1451743
Fix DE translations: Groups claim, Member fields, Save OIDC Settings; remove fuzzy
Some checks reported errors
continuous-integration/drone/push Build was killed
2026-02-24 15:50:47 +01:00
89a48cbaf7
Nitpick: add missing newline at EOF in settings resource_snapshots JSON files
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is passing
2026-02-24 15:42:27 +01:00
fae1804fb1
Code review: SignInLive locale fallback, single root + id, CSS scoped to #sign-in-page, remove or-hack, refresh oidc_configured after save, tests assert English only 2026-02-24 15:42:16 +01:00
c8d7dd3e55 Merge branch 'main' into feat/299_plz
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is failing
2026-02-24 15:38:50 +01:00
6417958ccc i18n: Update translations 2026-02-24 15:38:20 +01:00
aaa897c8dc style: restyle PDF export 2026-02-24 15:27:12 +01:00
951d01dc4d
Tests: accept DE or EN in auth controller sign-in and error assertions
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 15:13:24 +01:00
7af65d997b
Gettext: add DE/EN for OIDC-only labels and auth divider (or/oder) 2026-02-24 15:13:21 +01:00
2d1d1c62dc
Docs and .env.example: document OIDC_ONLY 2026-02-24 15:13:17 +01:00
249fd12db0
Dev: comment out OIDC defaults so sign-in hides SSO when not configured 2026-02-24 15:13:13 +01:00
3a98f70ba5
Locale: default German in dev/prod, English in test; validate locale in LocaleController 2026-02-24 15:13:10 +01:00
2cab4b0de4
Sign-in: custom SignInLive, OIDC-only mode and hide OIDC when not configured, locale divider or/oder 2026-02-24 15:13:05 +01:00
3f73a36076
GlobalSettings: oidc_only checkbox, ENV merge for OIDC, disable when OIDC not configured 2026-02-24 15:13:01 +01:00
c49758fc46
Secrets: return MissingSecret when OIDC values nil to avoid crashes 2026-02-24 15:12:58 +01:00
4b31578f6c
Config: oidc_configured?/0, oidc_only?/0, OIDC_ONLY ENV and settings fallback 2026-02-24 15:12:53 +01:00
e775fe118b
Setting: add oidc_only boolean attribute (ENV + DB) 2026-02-24 15:12:50 +01:00
adb44241d9
Add migration: oidc_only boolean to settings table 2026-02-24 15:12:45 +01:00
8fd2ee067e style: udate csv import 2026-02-24 15:07:34 +01:00
62b37b9aa2
feat: Datafields page, merge fee types into membership_fee_settings, sidebar
- Add /admin/datafields (DatafieldsLive) for member and custom field config
- Remove Memberdata block from GlobalSettingsLive
- Router: drop /membership_fee_types, add new_fee_type and edit_fee_type under membership_fee_settings
- MembershipFeeSettingsLive: fee types table, collapsible examples; Index links updated
- PagePaths: admin_datafields, admin_import; remove membership_fee_types
- Sidebar: order and labels (Basic settings, Datafields, Membership fee settings, Import, Users, Roles)
- Gettext: German translations for sidebar and OIDC
- Tests: datafields and fee routes, permission and form tests updated
2026-02-24 13:58:38 +01:00
8edbbac95f
feat: OIDC configuration in global Settings (ENV or DB)
- Add oidc_* attributes to Setting, migration and Config helpers
- Secrets and OidcRoleSyncConfig read from Config (ENV overrides DB)
- GlobalSettingsLive: OIDC section with disabled fields when ENV set
- OIDC role sync tests use DataCase for DB access
2026-02-24 13:58:24 +01:00
f29bbb02a2
feat: add Vereinfacht connection test button to settings 2026-02-24 13:09:34 +01:00
fca0194a7d Merge pull request 'feat: rename OIDC strategy, fix sidebar, UI improvements closes #271' (#444) from feat/ux_polishment into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #444
2026-02-24 13:05:10 +01:00
623543b7bd
fix: add missing postal_code in seeds
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
postal_code is a Vereinfacht-required field. When Vereinfacht is
configured, seeds failed for members without postal_code.
2026-02-24 12:06:56 +01:00
d95d4dc737
Fix gettext msgid for OIDC sign-in button after strategy rename
All checks were successful
continuous-integration/drone/push Build is passing
Phoenix.Naming.humanize(:oidc) = "Oidc", so the generated msgid is
"Sign in with Oidc" (previously "Sign in with Rauthy" for :rauthy).
Update all .po/.pot files so the "Single Sign On" translation matches.
2026-02-24 11:51:01 +01:00
12419c5237
docs: fix remaining rauthy references after oidc rename
Update action names (register_with_rauthy → register_with_oidc,
sign_in_with_rauthy → sign_in_with_oidc) and strategy name
(:rauthy → :oidc) in docs, code comments and guidelines.
2026-02-24 11:51:01 +01:00
29f262e1a1
fix: use pointer-events-none instead of disabled for active status button
Replace disabled attribute with pointer-events-none so the active
status button keeps its color (btn-success/warning/error btn-active)
instead of being grayed out by the browser's disabled styling.
2026-02-24 11:51:01 +01:00
2b8d898429
style: match cycle status buttons to filter button pattern
All three status buttons (Paid/Suspended/Unpaid) are now always
visible. The active status is highlighted with its color (btn-active),
inactive buttons are neutral gray - identical to the filter buttons.
2026-02-24 11:51:00 +01:00
76223b04e9
style: use btn-outline for all cycle status action buttons
Make Paid, Suspended and Unpaid buttons consistent by applying
btn-outline to all three, matching the outlined style pattern.
2026-02-24 11:51:00 +01:00
bee4a7db66
fix: remove debug console.log from SidebarState hook
Remove the temporary console.log statement that was added during
sidebar state debugging.
2026-02-24 11:51:00 +01:00
339d37937a
Rename OIDC strategy from :rauthy to :oidc, update callback path
- Rename AshAuthentication strategy from :oidc :rauthy to :oidc :oidc;
  generated actions are now register_with_oidc / sign_in_with_oidc.
- Update config keys (:rauthy → :oidc) in dev.exs and runtime.exs.
- Update default_redirect_uri to /auth/user/oidc/callback everywhere.
- Rename Mv.Accounts helper functions accordingly.
- Update Mv.Secrets, AuthController, link_oidc_account_live and all tests.
- Update docker-compose.prod.yml, .env.example, README and docs.

IMPORTANT: OIDC providers must be updated to use the new redirect URI
/auth/user/oidc/callback instead of /auth/user/rauthy/callback.
2026-02-24 11:51:00 +01:00
c637b6b84f
Fix sidebar dropdown direction and accidental mobile drawer opening
- CSS: When sidebar is collapsed, open user-menu dropdown to the right
  (left: 0, right: auto) via data-sidebar-expanded="false" selector.
- JS: Guard drawerToggle change handler – prevent mobile drawer from
  opening on desktop viewports (window.innerWidth >= 1024).
- HTML: Add phx-update="ignore" to mobile-drawer checkbox to prevent
  LiveView from resetting its checked state on DOM patches.
2026-02-24 11:50:59 +01:00
97fcae3e9d
Translate "Sign in with Rauthy" to "Single Sign On" via Gettext
Add manual msgid/msgstr entries in auth.po (de + en) and auth.pot for the
dynamically interpolated OAuth2 sign-in button label.
2026-02-24 11:50:59 +01:00
2d01c70c16
Group cycle status buttons with DaisyUI join component
Wrap Paid/Suspended/Unpaid buttons in a <div class="join"> and add
join-item to each button. Delete button stays separate next to the group.
2026-02-24 11:50:59 +01:00
0a59cf5c33
Sort custom fields by name as default in read action
Add `prepare build(sort: [name: :asc])` to the primary read action of
CustomField. Prevents order changes when toggling the `required` flag.
2026-02-24 11:50:59 +01:00
9a7608f9a1 Merge branch 'main' into feat/299_plz
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 11:44:19 +01:00
63040afee7 Merge branch 'main' into feat/299_plz
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 10:40:26 +01:00
c9d4254152 Merge pull request 'Member Fee Type in overview and exports, fix column visibility from URL' (#442) from feat/show_memberfeetype into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #442
2026-02-24 09:50:48 +01:00
10ad32eb6f
fix: treat URL with only custom fields as valid in ?fields= mode
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is passing
Consider visible custom fields in compute_final_field_selection so that
a link with only custom_field_X is not wrongly treated as invalid and
reverted to session/global. Add test for URL containing only custom field.
2026-02-24 09:42:25 +01:00
e8bcd88ee1 chore: updated template for csv
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 09:37:01 +01:00
9fc8c3b74a test: updated for country 2026-02-24 09:36:42 +01:00
3891c33204 i18n: update translation 2026-02-24 09:36:24 +01:00
2408978180 import: update csv with country 2026-02-24 09:35:49 +01:00
f681ca98b2 feat: adds country to member edit 2026-02-24 09:35:27 +01:00
e7668f1ef4 docs: adds country 2026-02-24 09:35:00 +01:00
1fd1880424 chore: adds country memberfield 2026-02-24 09:33:42 +01:00
d5df2338a7
test: export and PDF regression for Fee Type without start_date
All checks were successful
continuous-integration/drone/push Build is passing
Add test for CSV export with only first_name and membership_fee_type.
Add test for PDF export with same field set (status and content-type).
2026-02-24 09:30:15 +01:00
1c8c5ae83b
fix: include Fee Type in export when Start Date not in fields
Append membership_fee_type to column list when it is visible but
membership_fee_start_date was not in the selection (MemberExport,
export_column_order, build_export_member_fields_list).
2026-02-24 09:30:11 +01:00
94bcb5dc8c
fix: sort Fee Type by name in LiveView and exports
Use Ash related-field sort (membership_fee_type.name) instead of
membership_fee_type_id so column order is alphabetical. Load
membership_fee_type when sorting by it even if column is hidden.
In-memory re-sort (Build) uses loaded fee type name.
2026-02-24 09:30:04 +01:00
d41d13d122
fix(members): restore column visibility from URL on reload
All checks were successful
continuous-integration/drone/push Build is passing
Read 'fields' from URI when conn.params has no query (e.g. full page load).
When ?fields=... is present use URL-only selection so columns are not
merged with global settings. Fall back to session+global when URL has
only invalid field names.
2026-02-24 01:06:46 +01:00
e86c78a0dc
feat(export): include Fee Type and groups in PDF export
MemberExport allowlist and insert_fee_type; Build load/sort/cell_value;
MemberPdfExportController allow membership_fee_type and groups.
2026-02-24 00:20:29 +01:00
8db24405fa
test: fee type column visibility, CSV export, export controller
FieldVisibility pseudo fields and visible selection; MembersCSV fee type
column; export accepts membership_fee_type and returns Fee Type column.
2026-02-23 23:55:13 +01:00
f3b213ecec
feat(export): include Fee Type in CSV export
Payload and column_order when visible; allowlist, load, sort;
MembersCSV cell for :membership_fee_type.
2026-02-23 23:55:08 +01:00
68ceaced0c
feat(members): show and sort by Fee Type in member overview
Load membership_fee_type when column visible; sort by membership_fee_type_id;
add table column with SortHeader and fee type name.
2026-02-23 23:55:03 +01:00
b7ef69813b
feat(members): add Fee Type label and gettext strings
MemberFields.label(:membership_fee_type), DE: Beitragsart.
2026-02-23 23:54:59 +01:00
5715a22b0c
feat(members): add membership_fee_type to overview pseudo fields
Allow Fee Type as selectable column in member overview dropdown.
2026-02-23 23:54:50 +01:00
0f51bc89c3 Merge pull request 'Configurable member field "required" flag and Vereinfacht-required fields closes #440' (#441) from fix/required_fields into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #441
2026-02-23 23:28:34 +01:00
b3b8b31c0f
Member: skip required custom fields validation for set_vereinfacht_contact_id
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
Validation runs only for create_member and update_member so Vereinfacht
sync (which only sets vereinfacht_contact_id) no longer fails with
Required custom fields missing.
2026-02-23 23:07:38 +01:00
e9ed61a8fd
Tests: restore settings in on_exit to avoid leftover state
Setup + on_exit save/restore member_field_visibility and
member_field_required in member, setting, index_component and
form_error_handling tests.
2026-02-23 22:51:18 +01:00
50c4ab049d
core_components: set aria-required for required inputs (WCAG)
ensure_aria_required_for_input/1 adds aria-required when required
in rest; applied to select, textarea and default input.
2026-02-23 22:51:13 +01:00
717b8f5676
UpdateSingleMemberField: error attribution, updated_at, snapshot newline
Attach errors to :field, :show_in_overview, :member_field_required.
Set updated_at in SQL UPDATE. Add trailing newline to snapshot JSON.
2026-02-23 22:50:51 +01:00
0d1b776e78
Member: enforce email + Vereinfacht-required when get_settings fails
Compute vereinfacht_required? outside case; on error log and validate
only base required (email + Vereinfacht fields), not full settings.
2026-02-23 22:50:43 +01:00
cca2ca4632
FormComponent: persist vereinfacht_required_field? in socket
Assign in assign_form so validate/save enforce server-side without
relying on render assigns; use socket.assigns.vereinfacht_required_field?
2026-02-23 22:50:34 +01:00
bbededf3b9
CODE_GUIDELINES: document member_field_required and Vereinfacht required fields
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 22:13:58 +01:00
d44c5bdf94
Tests: member required fields, setting, member field live, sync_contact
Add tests for required validation, update_single_member_field, form
required map. Add street/postal_code/city to sync_contact when Vereinfacht configured.
2026-02-23 22:13:53 +01:00
27b9cbe814
Member form: required per field from settings and Vereinfacht
Load settings, build member_field_required_map and pass required to
inputs for asterisk, tooltip and validation.
2026-02-23 22:13:46 +01:00
8933ad9d14
Member field settings: required checkbox, line break, toggle fix
Index/Form use member_field_required; Required disabled for email and
Vereinfacht-required fields with tooltip. Rebuild form with to_form
on validate to fix checkbox toggle. Add mt-4 block before Required.
2026-02-23 22:13:31 +01:00
17fd5e13d5
Member: validate configurable and Vereinfacht-required fields
Add validation for required member fields from settings and for
Vereinfacht-required fields when integration is configured.
2026-02-23 22:13:26 +01:00
fec2f7b6f6
Constants: add vereinfacht_required_member_fields
Defines first_name, last_name, street, postal_code, city as required
when Vereinfacht integration is active.
2026-02-23 22:13:16 +01:00
c86781c32b
Setting: add member_field_required and update_single_member_field
Add JSONB attribute member_field_required, migration, Change and
Membership code interface for atomic per-field required flag.
2026-02-23 22:13:08 +01:00
a1684f485c Merge pull request 'Vereinfacht accounting software API closes #431' (#432) from feature/vereinfacht_api into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #432
2026-02-23 21:18:44 +01:00
0f20e459e9
Gettext: Vereinfacht strings in du-form (i18n guidelines)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-23 20:49:38 +01:00
d9491dea9c
Member show: present? check for vereinfacht_contact_id in UI
Use vereinfacht_contact_present assign so empty string is not treated as present.
2026-02-23 20:49:34 +01:00
daaa4dc345
Vereinfacht: filter blank vereinfacht_contact_id in sync_members
Include members with empty string; use expr with ref for Ash filter.
2026-02-23 20:49:30 +01:00
8ffd842c38
Vereinfacht client: receipt allowlist, find_contact pagination, flatten nesting
- Receipt attrs: allowlist only (no String.to_atom on API input / DoS)
- find_contact_by_email: paginate through all pages (page[size]=100)
- Extract helpers to satisfy Credo max nesting depth
2026-02-23 20:49:19 +01:00
1f21afeb72
Setting: vereinfacht_api_key public? false
Reduce exposure of API key; keep sensitive? true.
2026-02-23 20:49:12 +01:00
3cdaa75fc1
Member: remove system-actor fallback in extract_existing_values
Per guidelines: actor must come from context. When nil, skip load and return empty map.
2026-02-23 20:49:00 +01:00
482a335d36
Fix config test: clear vereinfacht_app_url from settings so derived URL is used 2026-02-23 20:48:57 +01:00
68e6c74a67
Gettext: add DE translations for Vereinfacht receipts and app URL 2026-02-23 19:54:44 +01:00
b60ab3f392
Member show: Vereinfacht link only, receipts table from API
- Show only 'Kontakt in Vereinfacht anzeigen' link (no Contact ID / Debug)
- Button loads receipts via get_contact_with_receipts, table with formatted columns
2026-02-23 19:54:44 +01:00
ede3df12ef
SyncFlash: document :public ETS table option 2026-02-23 19:54:44 +01:00
6c22d889a1
Vereinfacht client: receipts API, fetch_contact refactor, isExternal
- get_contact_with_receipts(contact_id) with ?include=receipts
- fetch_contact/2, build_url_with_params, extract_receipts_from_response
- Filter external contacts by isExternal in find_contact_id_by_email
- Send isExternal: true in create/update payloads
2026-02-23 19:54:44 +01:00
140e4a9054
SyncContact: only run when relevant attributes changed
- Sync on create; on update only when synced attrs changed or no contact_id yet
- Reduces unnecessary API calls on unrelated member updates
2026-02-23 19:54:43 +01:00
1188320844
Restrict set_vereinfacht_contact_id to system actor
- Add ActorIsSystemUser policy check
- Member set_vereinfacht_contact_id only allowed for system user
2026-02-23 19:54:43 +01:00
9d3c72acff
Add Vereinfacht app URL setting and contact view URL
- Setting attribute vereinfacht_app_url, migration, .env.example
- Config: vereinfacht_app_url() from env/setting or derived from API URL
- Contact view URL uses app URL with /en/admin/finances/contacts/{id}
- Global settings: App URL field, read-only when VEREINFACHT_APP_URL set
- Tests: update contact view URL expectations
2026-02-23 19:54:43 +01:00
7db609deec
Gettext: translate Vereinfacht API validation messages to German 2026-02-23 19:54:42 +01:00
02245e6684
Clear Vereinfacht ENV in test_helper so tests never hit real API 2026-02-23 19:54:42 +01:00
124857cc9c
Vereinfacht: update existing contact when found by email
Before saving contact_id to member, sync current data to the
existing contact so Vereinfacht stays up to date.
2026-02-23 19:54:42 +01:00
bc2d91f9e7
Vereinfacht client: find by email in response, no retries in test
API does not allow filter[email]; fetch list and match client-side.
Disable Req retries in test for fast failure and less log noise.
2026-02-23 19:54:42 +01:00
c33199465c
Gettext: new Vereinfacht UI strings and German translations
(set), Leave blank to keep current, env hint; DE msgstr added.
2026-02-23 19:54:42 +01:00
e1e0469e41
Global settings: API key redaction and per-field ENV
Never put API key in form/DOM; show (set) badge, drop blank on save.
Per-field disabled when ENV set; save button only when not all from ENV.
2026-02-23 19:54:41 +01:00
f2bcf68da2
Config: per-field Vereinfacht ENV helpers
vereinfacht_api_url_env_set?, vereinfacht_api_key_env_set?,
vereinfacht_club_id_env_set? for read-only Settings fields when set.
2026-02-23 19:54:41 +01:00
17488a6f42
Add Vereinfacht ENV vars to .env.example
VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID
with short comment that they override Settings when set.
2026-02-23 19:54:41 +01:00
a94c0c0b14
Vereinfacht: sync linked member only when email or member changed
Run SyncLinkedMemberAfterUserChange only when email or member
relationship changed to avoid unnecessary API calls.
2026-02-23 19:54:41 +01:00
a23f999eee
fix(a11y): WCAG 2 AA contrast and keyboard access 2026-02-23 19:54:36 +01:00
e4e6cfdd47
test(vereinfacht): add tests and scope README
- Config, Client, SyncContact, Vereinfacht module tests (no real API)
- vereinfacht_test_README: document test scope
2026-02-23 19:53:20 +01:00
c46365576d
feat(vereinfacht): gettext and German translations
- POT/PO: Vereinfacht UI and API error message strings
2026-02-23 19:53:17 +01:00
376086ae0f
feat(vereinfacht): member form flash and show page
- Form: show Vereinfacht sync warning after save via SyncFlash
- Show: load API debug response; MembershipFees: contact ID, link, no-contact warning
2026-02-23 19:51:32 +01:00
5343b78750
feat(vereinfacht): Settings UI and bulk sync
- GlobalSettingsLive: Vereinfacht section, sync button, last sync result
- Test: Vereinfacht Integration section visible
2026-02-23 19:51:32 +01:00
32efe380b7
feat(vereinfacht): sync linked member after user email/link changes
- SyncLinkedMemberAfterUserChange on update, create_user, update_user,
  admin_set_password, link_oidc_id, register_with_rauthy
2026-02-23 19:51:31 +01:00
a008cf381a
feat(vereinfacht): add client, sync flash and SyncContact change
- Application: create SyncFlash ETS table on start
- Vereinfacht: Client, SyncFlash, sync_member, format_error, sync_members_without_contact
- SyncContact change on Member create_member and update_member
- Member: attribute vereinfacht_contact_id, internal action set_vereinfacht_contact_id
2026-02-23 19:51:31 +01:00
a5a4d66655
feat(vereinfacht): add DB schema, config and setting attributes
- Migrations: vereinfacht_contact_id on members, vereinfacht_* on settings
- Mv.Config: Vereinfacht ENV/Settings helpers, vereinfacht_configured?, contact_view_url
- Setting: vereinfacht_api_url, api_key, club_id
2026-02-23 19:51:31 +01:00
47284fee98 Merge pull request '[fix] update debian image to trixie (stable) to fix imprintor glibc version mismatch' (#439) from fix/imprintor into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #439
2026-02-23 18:14:01 +01:00
91839dc426 fix: update debian image to trixie (stable) to fix imprintor glibc version mismatch
Some checks reported errors
continuous-integration/drone/push Build was killed
2026-02-23 18:13:37 +01:00
be9d12f181 Merge pull request 'finalize groups' (#437) from feature/finalize-groups into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #437
2026-02-23 17:27:48 +01:00
2619e3ea29 Merge pull request 'Implements exporting groups closes #428' (#435) from feature/428_export_groups into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #435
2026-02-23 16:25:40 +01:00
056fd04ddf feat: remove postal code validation 2026-02-23 16:24:20 +01:00
01d901a61d Merge branch 'main' into feature/428_export_groups
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-23 16:11:13 +01:00
d37ba84a74 Merge pull request 'Fixes light dark mode toggle closes #429' (#434) from bug/429_light_dark_mode into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #434
2026-02-23 16:10:22 +01:00
381e09dd1d Merge branch 'main' into bug/429_light_dark_mode
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-23 15:32:47 +01:00
f3ca492b49 Merge pull request 'Fixes missing Rauthy error message closes #289' (#427) from bug/289_rauthy_error_message into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #427
2026-02-23 15:31:58 +01:00
2f8df3f39d Merge branch 'main' into bug/289_rauthy_error_message
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-23 15:19:10 +01:00
3ecbd964ba Merge branch 'main' into feature/428_export_groups
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-23 15:14:03 +01:00
8430069b45
chore: add dev db seeds for groups
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-20 17:16:29 +01:00
123227a50e
refactor: add data-testid selectors for groups ui
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-20 16:34:15 +01:00
83b104ecf3
refactor: when adding group members, search in-memory on typing 2026-02-20 15:56:12 +01:00
ec814a8c94
refactor: remove db read on focus for groups view 2026-02-20 15:09:37 +01:00
397f7a7975 fix linting
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-20 09:16:38 +01:00
cb932ad6ef feat: respects sorting groups for export
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-20 08:45:55 +01:00
dbdac5870a fix: adds shoe/hide for group column 2026-02-20 08:45:21 +01:00
01f62297fc feat: add groups to export 2026-02-19 14:36:35 +01:00
cbed65de66 feat: fix light dark mode issue
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-19 12:55:29 +01:00
3491b4b1ba chore: set max cases testing for drone lower 2026-02-19 12:55:14 +01:00
2315f2588f Merge branch 'main' into bug/289_rauthy_error_message
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-19 10:02:30 +01:00
d1fefcca7d formatting
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is failing
2026-02-18 16:18:26 +01:00
b5fc03e94f refactor
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-18 16:10:46 +01:00
ac13a39e7c Merge branch 'main' into bug/289_rauthy_error_message
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-18 12:53:48 +01:00
002d723d0e fix: tests and flash layout 2026-02-18 12:53:25 +01:00
a25263b721 fix: adds user friendly flas message 2026-02-17 19:29:49 +01:00
148 changed files with 9162 additions and 1543 deletions

View file

@ -84,7 +84,7 @@ steps:
# Fetch dependencies
- mix deps.get
# Run fast tests (excludes slow/performance and UI tests)
- mix test --exclude slow --exclude ui
- mix test --exclude slow --exclude ui --max-cases 2
- name: rebuild-cache
image: drillster/drone-volume-cache
@ -273,7 +273,7 @@ environment:
steps:
- name: renovate
image: renovate/renovate:43.31
image: renovate/renovate:43.35
environment:
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
RENOVATE_TOKEN:

View file

@ -22,11 +22,22 @@ ASSOCIATION_NAME="Sportsclub XYZ"
# These have defaults in docker-compose.prod.yml, only override if needed
# OIDC_CLIENT_ID=mv
# OIDC_BASE_URL=http://localhost:8080/auth/v1
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
# OIDC_CLIENT_SECRET=your-rauthy-client-secret
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/oidc/callback
# OIDC_CLIENT_SECRET=your-oidc-client-secret
# Optional: OIDC group → Admin role sync (e.g. Authentik groups from profile scope)
# If OIDC_ADMIN_GROUP_NAME is set, users in that group get Admin role on registration/sign-in.
# OIDC_GROUPS_CLAIM defaults to "groups" (JWT claim name for group list).
# OIDC_ADMIN_GROUP_NAME=admin
# OIDC_GROUPS_CLAIM=groups
# Optional: Show only OIDC sign-in on login page (hide password form).
# When set to true and OIDC is configured, users see only the Single Sign-On button.
# OIDC_ONLY=true
# Optional: Vereinfacht accounting integration (finance-contacts sync)
# If set, these override values from Settings UI; those fields become read-only.
# VEREINFACHT_API_URL=https://api.verein.visuel.dev/api/v1
# VEREINFACHT_API_KEY=your-api-key
# VEREINFACHT_CLUB_ID=2
# VEREINFACHT_APP_URL=https://app.verein.visuel.dev

View file

@ -983,9 +983,9 @@ defmodule Mv.Accounts.User do
hashed_password_field :hashed_password
end
oauth2 :rauthy do
oidc :oidc do
client_id fn _, _ ->
Application.fetch_env!(:mv, :rauthy)[:client_id]
Application.fetch_env!(:mv, :oidc)[:client_id]
end
# ... other config
end
@ -1264,6 +1264,8 @@ end
### 3.12 Internationalization: Gettext
**German (de):** Use informal address (“duzen”). All user-facing German text should address the user as “du” (e.g. “Bitte versuche es erneut”, “Deine Einstellungen”), not “Sie”.
**Define Translations:**
```elixir
@ -1864,7 +1866,7 @@ authentication do
hashed_password_field :hashed_password
end
oauth2 :rauthy do
oidc :oidc do
# OIDC configuration
end
end
@ -2091,7 +2093,7 @@ plug :protect_from_forgery
```elixir
# config/runtime.exs
config :mv, :rauthy,
config :mv, :oidc,
client_id: System.get_env("OIDC_CLIENT_ID") || "mv",
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
base_url: System.get_env("OIDC_BASE_URL")
@ -2847,12 +2849,14 @@ Building accessible applications ensures that all users, including those with di
**Required Fields:**
Which member fields are required (asterisk, tooltip, validation) is configured in **Settings** (Memberdata section: edit a member field and set "Required"). The member create/edit form and Member resource validation both read `settings.member_field_required`. Email is always required; other fields default to optional.
```heex
<!-- Mark required fields -->
<!-- Mark required fields (value from settings or always true for email) -->
<.input
field={@form[:first_name]}
label={gettext("First Name")}
required
required={@member_field_required_map[:first_name]}
aria-required="true"
/>
```

View file

@ -7,25 +7,25 @@
# This file is based on these images:
#
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20250317-slim - for the release image
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=trixie-20260202-slim - for the release image
# - https://pkgs.org/ - resource for finding needed packages
# - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim
# - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim
#
ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim"
ARG RUNNER_IMAGE="debian:bullseye-20250317-slim"
ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim"
ARG RUNNER_IMAGE="debian:trixie-20260202-slim"
FROM ${BUILDER_IMAGE} AS builder
# install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
# prepare build dir
WORKDIR /app
# install hex + rebar
RUN mix local.hex --force && \
mix local.rebar --force
mix local.rebar --force
# set build ENV
ENV MIX_ENV="prod"
@ -64,7 +64,7 @@ RUN mix release
FROM ${RUNNER_IMAGE}
RUN apt-get update -y && \
apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
apt-get install -y libstdc++6 openssl libncurses6 locales ca-certificates \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
# Set the locale

View file

@ -142,7 +142,7 @@ Mila uses OIDC for Single Sign-On. In development, a local **Rauthy** instance i
3. Login with "admin@localhost" and password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in docker-compose.yml
4. add client from the admin panel
- Client ID: mv
- redirect uris: http://localhost:4000/auth/user/rauthy/callback
- redirect uris: http://localhost:4000/auth/user/oidc/callback
- Authorization Flows: authorization_code
- allowed origins: http://localhost:4000
- access/id token algortihm: RS256 (EDDSA did not work for me, found just few infos in the ashauthentication docs)
@ -153,13 +153,13 @@ Now you can log in to Mila via OIDC!
### OIDC with other providers (Authentik, Keycloak, etc.)
Mila works with any OIDC-compliant provider. The internal strategy is named `:rauthy`, but this is just a name — it works with any provider.
Mila works with any OIDC-compliant provider. The internal strategy is named `:oidc` — it works with any OIDC-compliant provider.
**Important:** The redirect URI must always end with `/auth/user/rauthy/callback`.
**Important:** The redirect URI must always end with `/auth/user/oidc/callback`.
Example for Authentik:
1. Create an OAuth2/OpenID Provider in Authentik
2. Set the redirect URI to: `https://your-domain.com/auth/user/rauthy/callback`
2. Set the redirect URI to: `https://your-domain.com/auth/user/oidc/callback`
3. Configure environment variables:
```bash
DOMAIN=your-domain.com # or PHX_HOST=your-domain.com
@ -168,7 +168,7 @@ Example for Authentik:
OIDC_CLIENT_SECRET=your-client-secret # or use OIDC_CLIENT_SECRET_FILE
```
The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/rauthy/callback` if not explicitly set.
The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/oidc/callback` if not explicitly set.
## ⚙️ Configuration
@ -238,7 +238,7 @@ For testing the production Docker build locally:
# OIDC_CLIENT_ID=mv
# OIDC_BASE_URL=http://localhost:8080/auth/v1
# OIDC_CLIENT_SECRET=<from-your-oidc-provider>
# OIDC_REDIRECT_URI is auto-generated as https://{DOMAIN}/auth/user/rauthy/callback
# OIDC_REDIRECT_URI is auto-generated as https://{DOMAIN}/auth/user/oidc/callback
# Alternative: Use _FILE variables for Docker secrets (takes priority over regular vars):
# SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base

View file

@ -24,7 +24,7 @@
@plugin "../vendor/daisyui-theme" {
name: "dark";
default: false;
prefersdark: true;
prefersdark: false;
color-scheme: "dark";
--color-base-100: oklch(30.33% 0.016 252.42);
--color-base-200: oklch(25.26% 0.014 253.1);
@ -99,6 +99,25 @@
/* Make LiveView wrapper divs transparent for layout */
[data-phx-session] { display: contents }
/* WCAG 1.4.12 Text Spacing: allow user stylesheets to adjust text spacing in popovers.
Popover content (e.g. from DaisyUI dropdown) must not rely on non-overridable inline
spacing; use inherited values so custom stylesheets can override. */
[popover] {
line-height: inherit;
letter-spacing: inherit;
word-spacing: inherit;
}
/* WCAG 2 AA: success/error text on light backgrounds (e.g. base-200). Use instead of
text-success/text-error when contrast ratio of theme colors is insufficient. */
.text-success-aa {
color: oklch(0.35 0.12 165);
}
.text-error-aa {
color: oklch(0.45 0.2 25);
}
/* ============================================
Sidebar Base Styles
============================================ */
@ -338,4 +357,36 @@
}
}
/* ============================================
Collapsed Sidebar: User Menu Dropdown Richtung
============================================ */
/* Bei eingeklappter Sidebar liegt der Avatar-Button am linken Rand.
dropdown-end würde das Menü nach links öffnen (off-screen).
Stattdessen nach rechts öffnen (in den Content-Bereich). */
#app-layout[data-sidebar-expanded="false"] .dropdown.dropdown-top > ul.dropdown-content {
right: auto !important;
left: 0 !important;
}
/* Sign-in: hide SSO button and "or" divider when OIDC is not configured.
Scoped to #sign-in-page to avoid hiding unrelated elements. */
#sign-in-page[data-oidc-configured="false"] [id*="oidc"] {
display: none !important;
}
#sign-in-page[data-oidc-configured="false"] a[href*="oidc"] {
display: none !important;
}
#sign-in-page[data-oidc-configured="false"] .divider {
display: none !important;
}
/* Sign-in: when OIDC-only mode is on, hide password form and "or" divider (show only SSO). */
#sign-in-page[data-oidc-configured="true"][data-oidc-only="true"] [id*="password"] {
display: none !important;
}
#sign-in-page[data-oidc-configured="true"][data-oidc-only="true"] .divider {
display: none !important;
}
/* This file is for your main application CSS */

View file

@ -86,6 +86,16 @@ Hooks.SidebarState = {
this.setSidebarState(!current)
}
},
updated() {
// LiveView patches data-sidebar-expanded back to the template default ("true")
// on every DOM update. Re-apply the stored state from localStorage after each patch.
const expanded = localStorage.getItem('sidebar-expanded') !== 'false'
const current = this.el.dataset.sidebarExpanded === 'true'
if (current !== expanded) {
this.setSidebarState(expanded)
}
},
setSidebarState(expanded) {
// Convert boolean to string for consistency
@ -228,6 +238,13 @@ document.addEventListener("DOMContentLoaded", () => {
// Listen for changes to the drawer checkbox
drawerToggle.addEventListener("change", () => {
// On desktop (lg:drawer-open), the mobile drawer must never open.
// The hamburger label is lg:hidden, but guard here as a safety net
// against any accidental toggles (e.g. from overlapping elements or JS).
if (drawerToggle.checked && window.innerWidth >= 1024) {
drawerToggle.checked = false
return
}
const isOpen = drawerToggle.checked
updateAriaExpanded()
updateSidebarTabIndex(isOpen)

View file

@ -93,11 +93,13 @@ config :mv, :secret_key_base, "ryn7D6ssmIHQFWIks2sFiTGATgwwAR1+3bN8p7fy6qVtB8qnx
# Signing Secret for Authentication
config :mv, :token_signing_secret, "IwUwi65TrEeExwBXXFPGm2I7889NsL"
config :mv, :rauthy,
client_id: "mv",
base_url: "http://localhost:8080/auth/v1",
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
redirect_uri: "http://localhost:4000/auth/user/rauthy/callback"
# OIDC: only use when ENV (or Settings) are set. When all OIDC ENVs are commented out,
# do not set defaults here so the SSO button stays hidden and no MissingSecret occurs.
# config :mv, :oidc,
# client_id: "mv",
# base_url: "http://localhost:8080/auth/v1",
# client_secret: System.get_env("OIDC_CLIENT_SECRET"),
# redirect_uri: "http://localhost:4000/auth/user/oidc/callback"
# AshAuthentication development configuration
config :mv, :session_identifier, :jti

View file

@ -129,8 +129,7 @@ if config_env() == :prod do
config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
# OIDC configuration (works with any OIDC provider: Authentik, Rauthy, Keycloak, etc.)
# Note: The strategy is named :rauthy internally, but works with any OIDC provider.
# The redirect_uri callback path is always /auth/user/rauthy/callback regardless of provider.
# The redirect_uri callback path is /auth/user/oidc/callback.
#
# 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).
@ -150,9 +149,9 @@ if config_env() == :prod do
# Build redirect_uri: use OIDC_REDIRECT_URI if set, otherwise build from host.
# Uses HTTPS since production runs behind TLS termination.
default_redirect_uri = "https://#{host}/auth/user/rauthy/callback"
default_redirect_uri = "https://#{host}/auth/user/oidc/callback"
config :mv, :rauthy,
config :mv, :oidc,
client_id: oidc_client_id || "mv",
base_url: oidc_base_url || "http://localhost:8080/auth/v1",
client_secret: client_secret,

View file

@ -49,6 +49,9 @@ config :mv, :session_identifier, :unsafe
config :mv, :require_token_presence_for_authentication, false
# Use English as default locale in tests so UI tests can assert on English strings.
config :mv, :default_locale, "en"
# Enable SQL Sandbox for async LiveView tests
# This flag controls sync vs async behavior in CycleGenerator after_action hooks
config :mv, :sql_sandbox, true

View file

@ -18,11 +18,11 @@ services:
PHX_HOST: "${PHX_HOST:-localhost}"
PORT: "4001"
PHX_SERVER: "true"
# Rauthy OIDC config - use host.docker.internal to reach host services
# OIDC config - use host.docker.internal to reach host services
OIDC_CLIENT_ID: "mv"
OIDC_BASE_URL: "http://host.docker.internal:8080/auth/v1"
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/oidc/callback"
secrets:
- db_password
- secret_key_base

View file

@ -33,14 +33,18 @@
- `OIDC_GROUPS_CLAIM` JWT claim name for group list (default "groups").
- Module: Mv.OidcRoleSyncConfig (oidc_admin_group_name/0, oidc_groups_claim/0).
### Sign-in page (OIDC-only mode)
- `OIDC_ONLY` (or Settings → OIDC → "Only OIDC sign-in") When set to true/1/yes and OIDC is configured, the sign-in page shows only the Single Sign-On button (password login is hidden). ENV takes precedence over Settings.
### Sync Logic
- Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info) If admin group configured, sets user role to Admin or Mitglied based on user_info groups.
### Where It Runs
1. Registration: register_with_rauthy after_action calls OidcRoleSync.
2. Sign-in: sign_in_with_rauthy prepare after_action calls OidcRoleSync for each user.
1. Registration: register_with_oidc after_action calls OidcRoleSync.
2. Sign-in: sign_in_with_oidc prepare after_action calls OidcRoleSync for each user.
### Internal Action

View file

@ -48,7 +48,7 @@ A **basic CSV member import feature** that allows administrators to upload a CSV
- Upload CSV file via LiveView file upload
- Parse CSV with bilingual header support for core member fields (English/German)
- Auto-detect delimiter (`;` or `,`) using header recognition
- Map CSV columns to core member fields (`first_name`, `last_name`, `email`, `street`, `postal_code`, `city`)
- Map CSV columns to core member fields (`first_name`, `last_name`, `email`, `street`, `postal_code`, `city`, `country`)
- **Import custom field values** - Map CSV columns to existing custom fields by name (unknown custom field columns will be ignored with a warning)
- Validate each row (required field: `email`)
- Create members via Ash resource (one-by-one, **no background jobs**, processed in chunks of 200 rows via LiveView messages)
@ -149,13 +149,26 @@ A **basic CSV member import feature** that allows administrators to upload a CSV
**v1 Supported Fields:**
**Core Member Fields:**
**Core Member Fields (all importable):**
- `email` / `E-Mail` (required)
- `first_name` / `Vorname` (optional)
- `last_name` / `Nachname` (optional)
- `email` / `E-Mail` (required)
- `street` / `Straße` (optional)
- `postal_code` / `PLZ` / `Postleitzahl` (optional)
- `join_date` / `Beitrittsdatum` (optional, ISO-8601 date)
- `exit_date` / `Austrittsdatum` (optional, ISO-8601 date)
- `notes` / `Notizen` (optional)
- `country` / `Land` / `Staat` (optional)
- `city` / `Stadt` (optional)
- `street` / `Straße` (optional)
- `house_number` / `Hausnummer` / `Nr.` (optional)
- `postal_code` / `PLZ` / `Postleitzahl` (optional)
- `membership_fee_start_date` / `Beitragsbeginn` (optional, ISO-8601 date)
Address column order in import/export matches the members overview: country, city, street, house number, postal code.
**Not supported for import (by design):**
- **membership_fee_status** Computed field (from fee cycles). Not stored; export-only.
- **groups** Many-to-many relationship. Would require resolving group names to IDs; not in current scope.
- **membership_fee_type_id** Foreign key; could be added later (e.g. resolve type name to ID).
**Custom Fields:**
- Any custom field column using the custom field's **name** as the header (e.g., `membership_number`, `birth_date`)
@ -176,9 +189,15 @@ A **basic CSV member import feature** that allows administrators to upload a CSV
| `first_name` | `first_name`, `firstname` | `Vorname`, `vorname` |
| `last_name` | `last_name`, `lastname`, `surname` | `Nachname`, `nachname`, `Familienname` |
| `email` | `email`, `e-mail`, `e_mail` | `E-Mail`, `e-mail`, `e_mail` |
| `join_date` | `join date`, `join_date` | `Beitrittsdatum`, `beitritts-datum` |
| `exit_date` | `exit date`, `exit_date` | `Austrittsdatum`, `austritts-datum` |
| `notes` | `notes` | `Notizen`, `bemerkungen` |
| `street` | `street`, `address` | `Straße`, `strasse`, `Strasse` |
| `house_number` | `house number`, `house_number`, `house no` | `Hausnummer`, `Nr`, `Nr.`, `Nummer` |
| `postal_code` | `postal_code`, `zip`, `postcode` | `PLZ`, `plz`, `Postleitzahl`, `postleitzahl` |
| `city` | `city`, `town` | `Stadt`, `stadt`, `Ort` |
| `country` | `country` | `Land`, `land`, `Staat`, `staat` |
| `membership_fee_start_date` | `membership fee start date`, `membership_fee_start_date`, `fee start` | `Beitragsbeginn`, `beitrags-beginn` |
**Header Normalization (used consistently for both input headers AND mapping variants):**
- Trim whitespace

View file

@ -191,7 +191,8 @@ Settings (1) → MembershipFeeType (0..1)
- Join date cannot be in future
- Exit date must be after join date
- Phone: `+?[0-9\- ]{6,20}`
- Postal code: 5 digits
- Postal code: optional (no format validation)
- Country: optional
### CustomFieldValue System
- Maximum one custom field value per custom field per member
@ -240,7 +241,7 @@ Settings (1) → MembershipFeeType (0..1)
### Weighted Fields
- **Weight A (highest):** first_name, last_name
- **Weight B:** email, notes, group names (from member_groups → groups)
- **Weight C:** city, street, house_number, postal_code, custom_field_values
- **Weight C:** city, street, house_number, postal_code, country, custom_field_values
- **Weight D (lowest):** join_date, exit_date
### Group Names in Search

View file

@ -131,6 +131,7 @@ Table members {
street text [null, note: 'Street name']
house_number text [null, note: 'House number']
postal_code text [null, note: '5-digit German postal code']
country text [null, note: 'Country of residence']
search_vector tsvector [null, note: 'Full-text search index (auto-generated)']
membership_fee_type_id uuid [null, note: 'FK to membership_fee_types - assigned fee type']
membership_fee_start_date date [null, note: 'Date from which membership fees should be calculated']
@ -188,7 +189,8 @@ Table members {
- email: 5-254 characters, valid email format (required)
- join_date: cannot be in future
- exit_date: must be after join_date (if both present)
- postal_code: exactly 5 digits (if present)
- postal_code: optional (no format validation)
- country: optional
'''
}

View file

@ -886,7 +886,7 @@ just regen-migrations <name>
**Checklist:**
1. ✅ Rauthy running: `docker compose ps`
2. ✅ Client created in Rauthy admin panel
3. ✅ Redirect URI matches exactly: `http://localhost:4000/auth/user/rauthy/callback`
3. ✅ Redirect URI matches exactly: `http://localhost:4000/auth/user/oidc/callback`
4. ✅ OIDC_CLIENT_SECRET in .env
5. ✅ App restarted after .env update

View file

@ -501,8 +501,8 @@ Since this is a **Phoenix LiveView** application with **Ash Framework**, we have
|--------|-------|---------|------|---------|----------|
| `GET` | `/auth/user/password/sign_in` | Show password login form | 🔓 | - | HTML form |
| `POST` | `/auth/user/password/sign_in` | Submit password login | 🔓 | `{email, password}` | Redirect + session cookie |
| `GET` | `/auth/user/rauthy` | Initiate OIDC flow | 🔓 | - | Redirect to Rauthy |
| `GET` | `/auth/user/rauthy/callback` | Handle OIDC callback | 🔓 | `{code, state}` | Redirect + session cookie |
| `GET` | `/auth/user/oidc` | Initiate OIDC flow | 🔓 | - | Redirect to Rauthy |
| `GET` | `/auth/user/oidc/callback` | Handle OIDC callback | 🔓 | `{code, state}` | Redirect + session cookie |
| `POST` | `/auth/user/sign_out` | Sign out user | 🔐 | - | Redirect to login |
| `GET` | `/auth/link-oidc-account` | OIDC account linking (password verification) | 🔓 | - | LiveView form | ✅ Implemented |
| `GET` | `/auth/user/password/reset` | Show password reset form | 🔓 | - | HTML form |
@ -515,9 +515,9 @@ Since this is a **Phoenix LiveView** application with **Ash Framework**, we have
| Resource | Action | Purpose | Auth | Input | Output |
|----------|--------|---------|------|-------|--------|
| `User` | `:sign_in_with_password` | Password authentication | 🔓 | `{email, password}` | `{:ok, user}` or `{:error, reason}` |
| `User` | `:sign_in_with_rauthy` | OIDC authentication | 🔓 | `{oidc_id, email, user_info}` | `{:ok, user}` or `{:error, reason}` |
| `User` | `:sign_in_with_oidc` | OIDC authentication | 🔓 | `{oidc_id, email, user_info}` | `{:ok, user}` or `{:error, reason}` |
| `User` | `:register_with_password` | Create user with password | 🔓 | `{email, password}` | `{:ok, user}` |
| `User` | `:register_with_rauthy` | Create user via OIDC | 🔓 | `{oidc_id, email}` | `{:ok, user}` |
| `User` | `:register_with_oidc` | Create user via OIDC | 🔓 | `{oidc_id, email}` | `{:ok, user}` |
| `User` | `:request_password_reset` | Generate reset token | 🔓 | `{email}` | `{:ok, token}` |
| `User` | `:reset_password` | Reset password with token | 🔓 | `{token, password}` | `{:ok, user}` |
| `Token` | `:revoke` | Revoke authentication token | 🔐 | `{jti}` | `{:ok, token}` |

View file

@ -10,10 +10,10 @@ This feature implements secure account linking between password-based accounts a
#### 1. Security Fix: `lib/accounts/user.ex`
**Change**: The `sign_in_with_rauthy` action now filters by `oidc_id` instead of `email`.
**Change**: The `sign_in_with_oidc` action now filters by `oidc_id` instead of `email`.
```elixir
read :sign_in_with_rauthy do
read :sign_in_with_oidc do
argument :user_info, :map, allow_nil?: false
argument :oauth_tokens, :map, allow_nil?: false
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation

View file

@ -9,7 +9,7 @@ defmodule Mv.Accounts do
## Public API
The domain exposes these main actions:
- User CRUD: `create_user/1`, `list_users/0`, `update_user/2`, `destroy_user/1`
- Authentication: `create_register_with_rauthy/1`, `read_sign_in_with_rauthy/1`
- Authentication: `create_register_with_oidc/1`, `read_sign_in_with_oidc/1`
"""
use Ash.Domain,
extensions: [AshAdmin.Domain, AshPhoenix]
@ -24,8 +24,8 @@ defmodule Mv.Accounts do
define :list_users, action: :read
define :update_user, action: :update_user
define :destroy_user, action: :destroy
define :create_register_with_rauthy, action: :register_with_rauthy
define :read_sign_in_with_rauthy, action: :sign_in_with_rauthy
define :create_register_with_oidc, action: :register_with_oidc
define :read_sign_in_with_oidc, action: :sign_in_with_oidc
end
resource Mv.Accounts.Token

View file

@ -28,7 +28,7 @@ defmodule Mv.Accounts.User do
@doc """
AshAuthentication specific: Defines the strategies we want to use for authentication.
Currently password and SSO with Rauthy as OIDC provider
Currently password and SSO via OIDC (supports any provider: Authentik, Rauthy, Keycloak, etc.)
"""
authentication do
session_identifier Application.compile_env!(:mv, :session_identifier)
@ -52,7 +52,7 @@ defmodule Mv.Accounts.User do
end
strategies do
oidc :rauthy do
oidc :oidc do
client_id Mv.Secrets
base_url Mv.Secrets
redirect_uri Mv.Secrets
@ -88,7 +88,7 @@ defmodule Mv.Accounts.User do
# Always use one of these explicit create actions instead:
# - :create_user (for manual user creation with optional member link)
# - :register_with_password (for password-based registration)
# - :register_with_rauthy (for OIDC-based registration)
# - :register_with_oidc (for OIDC-based registration)
defaults [:read]
destroy :destroy do
@ -118,6 +118,8 @@ defmodule Mv.Accounts.User do
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:email)]
end
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
end
create :create_user do
@ -145,6 +147,8 @@ defmodule Mv.Accounts.User do
# Sync user email to member when linking (User → Member)
change Mv.EmailSync.Changes.SyncUserEmailToMember
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
end
update :update_user do
@ -178,6 +182,8 @@ defmodule Mv.Accounts.User do
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where any([changing(:email), changing(:member)])
end
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
end
# Internal update used only by SystemActor/bootstrap and tests to assign role to system user.
@ -211,6 +217,8 @@ defmodule Mv.Accounts.User do
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:email)]
end
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
end
# Action to link an OIDC account to an existing password-only user
@ -248,6 +256,8 @@ defmodule Mv.Accounts.User do
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:email)]
end
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
end
read :get_by_subject do
@ -257,7 +267,7 @@ defmodule Mv.Accounts.User do
prepare AshAuthentication.Preparations.FilterBySubject
end
read :sign_in_with_rauthy do
read :sign_in_with_oidc do
# Single record expected; required for AshAuthentication OAuth2 strategy (returns list of 0 or 1).
get? true
argument :user_info, :map, allow_nil?: false
@ -292,7 +302,7 @@ defmodule Mv.Accounts.User do
end)
end
create :register_with_rauthy do
create :register_with_oidc do
argument :user_info, :map, allow_nil?: false
argument :oauth_tokens, :map, allow_nil?: false
upsert? true
@ -328,6 +338,8 @@ defmodule Mv.Accounts.User do
# Sync user email to member when linking (User → Member)
change Mv.EmailSync.Changes.SyncUserEmailToMember
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
# Sync role from OIDC groups (e.g. admin group → Admin role) after user is created/updated
change fn changeset, _ctx ->
user_info = Ash.Changeset.get_argument(changeset, :user_info)

View file

@ -52,7 +52,8 @@ defmodule Mv.Membership.CustomField do
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
authorizers: [Ash.Policy.Authorizer],
primary_read_warning?: false
postgres do
table "custom_fields"
@ -60,9 +61,13 @@ defmodule Mv.Membership.CustomField do
end
actions do
defaults [:read]
default_accept [:name, :value_type, :description, :required, :show_in_overview]
read :read do
primary? true
prepare build(sort: [name: :asc])
end
create :create do
accept [:name, :value_type, :description, :required, :show_in_overview]
change Mv.Membership.Changes.GenerateSlug

View file

@ -22,7 +22,6 @@ defmodule Mv.Membership.Member do
## Validations
- Required: email (all other fields are optional)
- Email format validation (using EctoCommons.EmailValidator)
- Postal code format: exactly 5 digits (German format)
- Date validations: join_date not in future, exit_date after join_date
- Email uniqueness: prevents conflicts with unlinked users
- Linked member email change: only admins or the linked user may change a linked member's email (see `Mv.Membership.Member.Validations.EmailChangePermission`)
@ -117,6 +116,9 @@ defmodule Mv.Membership.Member do
# Requires both join_date and membership_fee_type_id to be present
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
# Sync member to Vereinfacht as finance contact (if configured)
change Mv.Vereinfacht.Changes.SyncContact
# Trigger cycle generation after member creation
# Only runs if membership_fee_type_id is set
# Note: Cycle generation runs asynchronously to not block the action,
@ -190,6 +192,9 @@ defmodule Mv.Membership.Member do
where [changing(:membership_fee_type_id)]
end
# Sync member to Vereinfacht as finance contact (if configured)
change Mv.Vereinfacht.Changes.SyncContact
# Trigger cycle regeneration when membership_fee_type_id changes
# This deletes future unpaid cycles and regenerates them with the new type/amount
# Note: Cycle regeneration runs synchronously in the same transaction to ensure atomicity
@ -243,6 +248,13 @@ defmodule Mv.Membership.Member do
end)
end
# Internal: set vereinfacht_contact_id after syncing with Vereinfacht API.
# Not exposed via code interface; used only by Mv.Vereinfacht.Changes.SyncContact.
update :set_vereinfacht_contact_id do
require_atomic? false
accept [:vereinfacht_contact_id]
end
# Action to handle fuzzy search on specific fields
read :search do
argument :query, :string, allow_nil?: true
@ -320,6 +332,12 @@ defmodule Mv.Membership.Member do
authorize_if Mv.Authorization.Checks.HasPermission
end
# Internal sync action: only SystemActor may set vereinfacht_contact_id (used by SyncContact change).
policy action(:set_vereinfacht_contact_id) do
description "Only system actor may set Vereinfacht contact ID"
authorize_if Mv.Authorization.Checks.ActorIsSystemUser
end
# CREATE/UPDATE: Forbid memberuser link unless admin, then check permissions
# ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty).
# HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all.
@ -458,11 +476,6 @@ defmodule Mv.Membership.Member do
where: [present([:join_date, :exit_date])],
message: "cannot be before join date"
# Postal code format (only if set)
validate match(:postal_code, ~r/^\d{5}$/),
where: [present(:postal_code)],
message: "must consist of 5 digits"
# Email validation with EctoCommons.EmailValidator
validate fn changeset, _ ->
email = Ash.Changeset.get_attribute(changeset, :email)
@ -481,48 +494,97 @@ defmodule Mv.Membership.Member do
end
end
# Validate required custom fields (actor from validation context only; no fallback)
# Validate required custom fields (actor from validation context only; no fallback).
# Only for create_member/update_member; skip for set_vereinfacht_contact_id (internal sync
# only sets vereinfacht_contact_id; custom fields were already validated and saved).
validate fn changeset, context ->
provided_values = provided_custom_field_values(changeset)
actor = context.actor
provided_values = provided_custom_field_values(changeset)
actor = context.actor
case Mv.Membership.list_required_custom_fields(actor: actor) do
{:ok, required_custom_fields} ->
missing_fields = missing_required_fields(required_custom_fields, provided_values)
case Mv.Membership.list_required_custom_fields(actor: actor) do
{:ok, required_custom_fields} ->
missing_fields =
missing_required_fields(required_custom_fields, provided_values)
if Enum.empty?(missing_fields) do
:ok
else
build_custom_field_validation_error(missing_fields)
end
if Enum.empty?(missing_fields) do
:ok
else
build_custom_field_validation_error(missing_fields)
end
{:error, %Ash.Error.Forbidden{}} ->
Logger.warning(
"Required custom fields validation: actor not authorized to read CustomField"
)
{:error, %Ash.Error.Forbidden{}} ->
Logger.warning(
"Required custom fields validation: actor not authorized to read CustomField"
)
{:error,
field: :custom_field_values,
message:
"You are not authorized to perform this action. Please sign in again or contact support."}
{:error,
field: :custom_field_values,
message:
"You are not authorized to perform this action. Please sign in again or contact support."}
{:error, :missing_actor} ->
Logger.warning("Required custom fields validation: no actor in context")
{:error, :missing_actor} ->
Logger.warning("Required custom fields validation: no actor in context")
{:error,
field: :custom_field_values,
message:
"You are not authorized to perform this action. Please sign in again or contact support."}
{:error,
field: :custom_field_values,
message:
"You are not authorized to perform this action. Please sign in again or contact support."}
{:error, error} ->
Logger.error(
"Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed."
)
{:error, error} ->
Logger.error(
"Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed."
)
{:error,
field: :custom_field_values,
message:
"Unable to validate required custom fields. Please try again or contact support."}
{:error,
field: :custom_field_values,
message:
"Unable to validate required custom fields. Please try again or contact support."}
end
end,
where: [action_is([:create_member, :update_member])]
# Validate member fields that are marked as required in settings or by Vereinfacht.
# When settings cannot be loaded, we still enforce email + Vereinfacht-required fields.
validate fn changeset, _context ->
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
required_fields =
case Mv.Membership.get_settings() do
{:ok, settings} ->
required_config = settings.member_field_required || %{}
normalized = VisibilityConfig.normalize(required_config)
Enum.filter(Mv.Constants.member_fields(), fn field ->
field == :email ||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
Map.get(normalized, field, false)
end)
{:error, reason} ->
Logger.warning(
"Member required-fields validation: could not load settings (#{inspect(reason)}). " <>
"Enforcing only email and Vereinfacht-required fields."
)
Enum.filter(Mv.Constants.member_fields(), fn field ->
field == :email ||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field))
end)
end
missing =
Enum.filter(required_fields, fn field ->
value = Ash.Changeset.get_attribute(changeset, field)
not member_field_value_present?(field, value)
end)
if Enum.empty?(missing) do
:ok
else
field = hd(missing)
{:error,
field: field, message: Gettext.dgettext(MvWeb.Gettext, "default", "can't be blank")}
end
end
end
@ -580,6 +642,10 @@ defmodule Mv.Membership.Member do
allow_nil? true
end
attribute :country, :string do
allow_nil? true
end
attribute :search_vector, AshPostgres.Tsvector,
writable?: false,
public?: false,
@ -593,6 +659,14 @@ defmodule Mv.Membership.Member do
public? true
description "Date from which membership fees should be calculated"
end
# Vereinfacht accounting software integration: ID of the finance contact synced via API.
# Set by Mv.Vereinfacht.Changes.SyncContact; not accepted in create/update actions.
attribute :vereinfacht_contact_id, :string do
allow_nil? true
public? true
description "ID of the finance contact in Vereinfacht (set by sync)"
end
end
relationships do
@ -1173,7 +1247,8 @@ defmodule Mv.Membership.Member do
contains(postal_code, ^query) or
contains(house_number, ^query) or
contains(email, ^query) or
contains(city, ^query)
contains(city, ^query) or
contains(country, ^query)
)
end
@ -1273,17 +1348,24 @@ defmodule Mv.Membership.Member do
end
end
# Extracts custom field values from existing member data (update scenario)
# Extracts custom field values from existing member data (update scenario).
# Actor must come from context; no system-actor fallback (per guidelines).
# When no actor is present we skip the load and return empty map.
defp extract_existing_values(member_data, changeset) do
actor = Map.get(changeset.context, :actor)
opts = Helpers.ash_actor_opts(actor)
case Ash.load(member_data, :custom_field_values, opts) do
{:ok, %{custom_field_values: existing_values}} ->
Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)
_ ->
case Map.get(changeset.context, :actor) do
nil ->
%{}
actor ->
opts = Helpers.ash_actor_opts(actor)
case Ash.load(member_data, :custom_field_values, opts) do
{:ok, %{custom_field_values: existing_values}} ->
Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)
_ ->
%{}
end
end
end
@ -1386,4 +1468,14 @@ defmodule Mv.Membership.Member do
defp value_present?(_value, :email), do: false
defp value_present?(_value, _type), do: false
# Used by member-field-required validation (settings-driven required fields)
defp member_field_value_present?(_field, nil), do: false
defp member_field_value_present?(_, value) when is_binary(value),
do: String.trim(value) != ""
defp member_field_value_present?(_, %Date{}), do: true
defp member_field_value_present?(_, value) when is_struct(value, Date), do: true
defp member_field_value_present?(_, _), do: false
end

View file

@ -64,6 +64,8 @@ defmodule Mv.Membership do
define :update_single_member_field_visibility,
action: :update_single_member_field_visibility
define :update_single_member_field, action: :update_single_member_field
end
resource Mv.Membership.Group do
@ -257,6 +259,46 @@ defmodule Mv.Membership do
|> Ash.update(domain: __MODULE__)
end
@doc """
Atomically updates visibility and required for a single member field.
Updates both `member_field_visibility` and `member_field_required` in one
operation. Use this when saving from the member field settings form.
## Parameters
- `settings` - The settings record to update
- `field` - The member field name as a string (e.g., "first_name", "street")
- `show_in_overview` - Boolean value indicating visibility in member overview
- `required` - Boolean value indicating whether the field is required in member forms
## Returns
- `{:ok, updated_settings}` - Successfully updated settings
- `{:error, error}` - Validation or update error
## Examples
iex> {:ok, settings} = Mv.Membership.get_settings()
iex> {:ok, updated} = Mv.Membership.update_single_member_field(settings, field: "first_name", show_in_overview: true, required: true)
iex> updated.member_field_required["first_name"]
true
"""
def update_single_member_field(settings,
field: field,
show_in_overview: show_in_overview,
required: required
) do
settings
|> Ash.Changeset.new()
|> Ash.Changeset.set_argument(:field, field)
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|> Ash.Changeset.set_argument(:required, required)
|> Ash.Changeset.for_update(:update_single_member_field, %{})
|> Ash.update(domain: __MODULE__)
end
@doc """
Gets a group by its slug.

View file

@ -11,6 +11,8 @@ defmodule Mv.Membership.Setting do
- `club_name` - The name of the association/club (required, cannot be empty)
- `member_field_visibility` - JSONB map storing visibility configuration for member fields
(e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`.
- `member_field_required` - JSONB map storing which member fields are required in forms
(e.g., `%{"first_name" => true, "last_name" => true}`). Email is always required; other fields default to optional.
- `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true)
- `default_membership_fee_type_id` - Default membership fee type for new members (optional)
@ -42,6 +44,9 @@ defmodule Mv.Membership.Setting do
# Update member field visibility
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
# Update visibility and required for a single member field (e.g. from settings UI)
{:ok, updated} = Mv.Membership.update_single_member_field(settings, field: "first_name", show_in_overview: true, required: true)
# Update membership fee settings
{:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false})
"""
@ -68,8 +73,20 @@ defmodule Mv.Membership.Setting do
accept [
:club_name,
:member_field_visibility,
:member_field_required,
:include_joining_cycle,
:default_membership_fee_type_id
:default_membership_fee_type_id,
:vereinfacht_api_url,
:vereinfacht_api_key,
:vereinfacht_club_id,
:vereinfacht_app_url,
:oidc_client_id,
:oidc_base_url,
:oidc_redirect_uri,
:oidc_client_secret,
:oidc_admin_group_name,
:oidc_groups_claim,
:oidc_only
]
end
@ -80,8 +97,20 @@ defmodule Mv.Membership.Setting do
accept [
:club_name,
:member_field_visibility,
:member_field_required,
:include_joining_cycle,
:default_membership_fee_type_id
:default_membership_fee_type_id,
:vereinfacht_api_url,
:vereinfacht_api_key,
:vereinfacht_club_id,
:vereinfacht_app_url,
:oidc_client_id,
:oidc_base_url,
:oidc_redirect_uri,
:oidc_client_secret,
:oidc_admin_group_name,
:oidc_groups_claim,
:oidc_only
]
end
@ -101,6 +130,17 @@ defmodule Mv.Membership.Setting do
change Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility
end
update :update_single_member_field do
description "Atomically updates visibility and required for a single member field"
require_atomic? false
argument :field, :string, allow_nil?: false
argument :show_in_overview, :boolean, allow_nil?: false
argument :required, :boolean, allow_nil?: false
change Mv.Membership.Setting.Changes.UpdateSingleMemberField
end
update :update_membership_fee_settings do
description "Updates the membership fee configuration"
require_atomic? false
@ -154,6 +194,44 @@ defmodule Mv.Membership.Setting do
end,
on: [:create, :update]
# Validate member_field_required map structure and content
validate fn changeset, _context ->
required_config = Ash.Changeset.get_attribute(changeset, :member_field_required)
if required_config && is_map(required_config) do
invalid_values =
Enum.filter(required_config, fn {_key, value} ->
not is_boolean(value)
end)
valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
invalid_keys =
Enum.filter(required_config, fn {key, _value} ->
key not in valid_field_strings
end)
|> Enum.map(fn {key, _value} -> key end)
cond do
not Enum.empty?(invalid_values) ->
{:error,
field: :member_field_required,
message: "All values in member_field_required must be booleans"}
not Enum.empty?(invalid_keys) ->
{:error,
field: :member_field_required,
message: "Invalid member field keys: #{inspect(invalid_keys)}"}
true ->
:ok
end
else
:ok
end
end,
on: [:create, :update]
# Validate default_membership_fee_type_id exists if set
validate fn changeset, context ->
fee_type_id =
@ -211,6 +289,12 @@ defmodule Mv.Membership.Setting do
description:
"Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
attribute :member_field_required, :map,
allow_nil?: true,
public?: true,
description:
"Configuration for which member fields are required in forms (JSONB map). Keys are member field names (strings), values are booleans. Email is always required."
# Membership fee settings
attribute :include_joining_cycle, :boolean do
allow_nil? false
@ -225,6 +309,79 @@ defmodule Mv.Membership.Setting do
description "Default membership fee type ID for new members"
end
# Vereinfacht accounting software integration (can be overridden by ENV)
attribute :vereinfacht_api_url, :string do
allow_nil? true
public? true
description "Vereinfacht API base URL (e.g. https://api.verein.visuel.dev/api/v1)"
end
attribute :vereinfacht_api_key, :string do
allow_nil? true
public? false
description "Vereinfacht API key (Bearer token)"
sensitive? true
end
attribute :vereinfacht_club_id, :string do
allow_nil? true
public? true
description "Vereinfacht club ID for multi-tenancy"
end
attribute :vereinfacht_app_url, :string do
allow_nil? true
public? true
description "Vereinfacht app base URL for contact view links (e.g. https://app.verein.visuel.dev)"
end
# OIDC authentication (can be overridden by ENV)
attribute :oidc_client_id, :string do
allow_nil? true
public? true
description "OIDC client ID (e.g. from OIDC_CLIENT_ID)"
end
attribute :oidc_base_url, :string do
allow_nil? true
public? true
description "OIDC provider base URL (e.g. from OIDC_BASE_URL)"
end
attribute :oidc_redirect_uri, :string do
allow_nil? true
public? true
description "OIDC redirect URI for callback (e.g. from OIDC_REDIRECT_URI)"
end
attribute :oidc_client_secret, :string do
allow_nil? true
public? false
description "OIDC client secret (e.g. from OIDC_CLIENT_SECRET)"
sensitive? true
end
attribute :oidc_admin_group_name, :string do
allow_nil? true
public? true
description "OIDC group name that maps to Admin role (e.g. from OIDC_ADMIN_GROUP_NAME)"
end
attribute :oidc_groups_claim, :string do
allow_nil? true
public? true
description "JWT claim name for group list (e.g. from OIDC_GROUPS_CLAIM, default 'groups')"
end
attribute :oidc_only, :boolean do
allow_nil? false
default false
public? true
description "When true and OIDC is configured, sign-in shows only OIDC (password login hidden)"
end
timestamps()
end

View file

@ -0,0 +1,179 @@
defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do
@moduledoc """
Ash change that atomically updates visibility and required for a single member field.
Updates both `member_field_visibility` and `member_field_required` JSONB maps
in one SQL UPDATE to avoid lost updates when saving from the settings UI.
## Arguments
- `field` - The member field name as a string (e.g., "street", "first_name")
- `show_in_overview` - Boolean value indicating visibility in member overview
- `required` - Boolean value indicating whether the field is required in member forms
## Example
settings
|> Ash.Changeset.for_update(:update_single_member_field, %{},
arguments: %{field: "first_name", show_in_overview: true, required: true}
)
|> Ash.update(domain: Mv.Membership)
"""
use Ash.Resource.Change
alias Ash.Error.Invalid
alias Ecto.Adapters.SQL
require Logger
def change(changeset, _opts, _context) do
with {:ok, field} <- get_and_validate_field(changeset),
{:ok, show_in_overview} <- get_and_validate_boolean(changeset, :show_in_overview),
{:ok, required} <- get_and_validate_boolean(changeset, :required) do
add_after_action(changeset, field, show_in_overview, required)
else
{:error, updated_changeset} -> updated_changeset
end
end
defp get_and_validate_field(changeset) do
case Ash.Changeset.get_argument(changeset, :field) do
nil ->
{:error,
add_error(changeset,
field: :field,
message: "field argument is required"
)}
field ->
valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
if field in valid_fields do
{:ok, field}
else
{:error,
add_error(
changeset,
field: :field,
message: "Invalid member field: #{field}"
)}
end
end
end
defp get_and_validate_boolean(changeset, :show_in_overview = arg_name) do
do_validate_boolean(changeset, arg_name, :show_in_overview)
end
defp get_and_validate_boolean(changeset, :required = arg_name) do
do_validate_boolean(changeset, arg_name, :member_field_required)
end
defp do_validate_boolean(changeset, arg_name, error_field) do
case Ash.Changeset.get_argument(changeset, arg_name) do
nil ->
{:error,
add_error(
changeset,
field: error_field,
message: "#{arg_name} argument is required"
)}
value when is_boolean(value) ->
{:ok, value}
_ ->
{:error,
add_error(
changeset,
field: error_field,
message: "#{arg_name} must be a boolean"
)}
end
end
defp add_error(changeset, opts) do
Ash.Changeset.add_error(changeset, opts)
end
defp add_after_action(changeset, field, show_in_overview, required) do
Ash.Changeset.after_action(changeset, fn _changeset, settings ->
# Update both JSONB columns in one statement
sql = """
UPDATE settings
SET
member_field_visibility = jsonb_set(
COALESCE(member_field_visibility, '{}'::jsonb),
ARRAY[$1::text],
to_jsonb($2::boolean),
true
),
member_field_required = jsonb_set(
COALESCE(member_field_required, '{}'::jsonb),
ARRAY[$1::text],
to_jsonb($3::boolean),
true
),
updated_at = (now() AT TIME ZONE 'utc')
WHERE id = $4
RETURNING member_field_visibility, member_field_required
"""
uuid_binary = Ecto.UUID.dump!(settings.id)
case SQL.query(Mv.Repo, sql, [field, show_in_overview, required, uuid_binary]) do
{:ok, %{rows: [[updated_visibility, updated_required] | _]}} ->
vis = normalize_jsonb_result(updated_visibility)
req = normalize_jsonb_result(updated_required)
updated_settings = %{
settings
| member_field_visibility: vis,
member_field_required: req
}
{:ok, updated_settings}
{:ok, %{rows: []}} ->
{:error,
Invalid.exception(
field: :member_field_required,
message: "Settings not found"
)}
{:error, error} ->
Logger.error("Failed to atomically update member field settings: #{inspect(error)}")
{:error,
Invalid.exception(
field: :member_field_required,
message: "Failed to update member field settings"
)}
end
end)
end
defp normalize_jsonb_result(updated_jsonb) do
case updated_jsonb do
map when is_map(map) ->
Enum.reduce(map, %{}, fn
{k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v)
{k, v}, acc -> Map.put(acc, k, v)
end)
binary when is_binary(binary) ->
case Jason.decode(binary) do
{:ok, decoded} when is_map(decoded) ->
decoded
{:ok, _} ->
%{}
{:error, reason} ->
Logger.warning("Failed to decode JSONB: #{inspect(reason)}")
%{}
end
_ ->
Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}")
%{}
end
end
end

View file

@ -7,6 +7,8 @@ defmodule Mv.Application do
@impl true
def start(_type, _args) do
Mv.Vereinfacht.SyncFlash.create_table!()
children = [
MvWeb.Telemetry,
Mv.Repo,

View file

@ -0,0 +1,15 @@
defmodule Mv.Authorization.Checks.ActorIsSystemUser do
@moduledoc """
Policy check: true only when the actor is the system user (e.g. system@mila.local).
Used to restrict internal actions (e.g. Member.set_vereinfacht_contact_id) so that
only code paths using SystemActor can perform them, not regular admins.
"""
use Ash.Policy.SimpleCheck
@impl true
def describe(_opts), do: "actor is the system user"
@impl true
def match?(actor, _context, _opts), do: Mv.Helpers.SystemActor.system_user?(actor)
end

View file

@ -142,4 +142,292 @@ defmodule Mv.Config do
|> Keyword.get(key, default)
|> parse_and_validate_integer(default)
end
# ---------------------------------------------------------------------------
# Vereinfacht accounting software integration
# ENV variables take priority; fallback to Settings from database.
# ---------------------------------------------------------------------------
@doc """
Returns the Vereinfacht API base URL.
Reads from `VEREINFACHT_API_URL` env first, then from Settings.
"""
@spec vereinfacht_api_url() :: String.t() | nil
def vereinfacht_api_url do
env_or_setting("VEREINFACHT_API_URL", :vereinfacht_api_url)
end
@doc """
Returns the Vereinfacht API key (Bearer token).
Reads from `VEREINFACHT_API_KEY` env first, then from Settings.
"""
@spec vereinfacht_api_key() :: String.t() | nil
def vereinfacht_api_key do
env_or_setting("VEREINFACHT_API_KEY", :vereinfacht_api_key)
end
@doc """
Returns the Vereinfacht club ID for multi-tenancy.
Reads from `VEREINFACHT_CLUB_ID` env first, then from Settings.
"""
@spec vereinfacht_club_id() :: String.t() | nil
def vereinfacht_club_id do
env_or_setting("VEREINFACHT_CLUB_ID", :vereinfacht_club_id)
end
@doc """
Returns the Vereinfacht app base URL for contact view links (frontend, not API).
Reads from `VEREINFACHT_APP_URL` env first, then from Settings.
Used to build links like https://app.verein.visuel.dev/en/admin/finances/contacts/{id}.
If not set, derived from API URL by replacing host \"api.\" with \"app.\" when possible.
"""
@spec vereinfacht_app_url() :: String.t() | nil
def vereinfacht_app_url do
env_or_setting("VEREINFACHT_APP_URL", :vereinfacht_app_url) ||
derive_app_url_from_api_url(vereinfacht_api_url())
end
defp derive_app_url_from_api_url(nil), do: nil
defp derive_app_url_from_api_url(api_url) when is_binary(api_url) do
api_url = String.trim(api_url)
uri = URI.parse(api_url)
host = uri.host || ""
if String.starts_with?(host, "api.") do
app_host = "app." <> String.slice(host, 4..-1//1)
scheme = uri.scheme || "https"
"#{scheme}://#{app_host}"
else
nil
end
end
defp derive_app_url_from_api_url(_), do: nil
@doc """
Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set).
"""
@spec vereinfacht_configured?() :: boolean()
def vereinfacht_configured? do
present?(vereinfacht_api_url()) and present?(vereinfacht_api_key()) and
present?(vereinfacht_club_id())
end
@doc """
Returns true if any Vereinfacht ENV variable is set (used to show hint in Settings UI).
"""
@spec vereinfacht_env_configured?() :: boolean()
def vereinfacht_env_configured? do
vereinfacht_api_url_env_set?() or vereinfacht_api_key_env_set?() or
vereinfacht_club_id_env_set?()
end
@doc """
Returns true if VEREINFACHT_API_URL is set (field is read-only in Settings).
"""
def vereinfacht_api_url_env_set?, do: env_set?("VEREINFACHT_API_URL")
@doc """
Returns true if VEREINFACHT_API_KEY is set (field is read-only in Settings).
"""
def vereinfacht_api_key_env_set?, do: env_set?("VEREINFACHT_API_KEY")
@doc """
Returns true if VEREINFACHT_CLUB_ID is set (field is read-only in Settings).
"""
def vereinfacht_club_id_env_set?, do: env_set?("VEREINFACHT_CLUB_ID")
@doc """
Returns true if VEREINFACHT_APP_URL is set (field is read-only in Settings).
"""
def vereinfacht_app_url_env_set?, do: env_set?("VEREINFACHT_APP_URL")
defp env_set?(key) do
case System.get_env(key) do
nil -> false
v when is_binary(v) -> String.trim(v) != ""
_ -> false
end
end
defp env_or_setting(env_key, setting_key) do
case System.get_env(env_key) do
nil -> get_vereinfacht_from_settings(setting_key)
value -> trim_nil(value)
end
end
defp env_or_setting_bool(env_key, setting_key) do
case System.get_env(env_key) do
nil ->
get_from_settings_bool(setting_key)
value when is_binary(value) ->
v = String.trim(value) |> String.downcase()
v in ["true", "1", "yes"]
_ ->
false
end
end
defp get_vereinfacht_from_settings(key) do
get_from_settings(key)
end
defp get_from_settings(key) do
case Mv.Membership.get_settings() do
{:ok, settings} -> settings |> Map.get(key) |> trim_nil()
{:error, _} -> nil
end
end
defp get_from_settings_bool(key) do
case Mv.Membership.get_settings() do
{:ok, settings} ->
case Map.get(settings, key) do
true -> true
_ -> false
end
{:error, _} ->
false
end
end
defp trim_nil(nil), do: nil
defp trim_nil(s) when is_binary(s) do
t = String.trim(s)
if t == "", do: nil, else: t
end
@doc """
Returns the URL to view a finance contact in the Vereinfacht app (frontend).
Uses the configured app base URL (or derived from API URL) and appends
/en/admin/finances/contacts/{id}. Returns nil if no app URL can be determined.
"""
@spec vereinfacht_contact_view_url(String.t()) :: String.t() | nil
def vereinfacht_contact_view_url(contact_id) when is_binary(contact_id) do
base = vereinfacht_app_url()
if present?(base) do
base
|> String.trim_trailing("/")
|> then(&"#{&1}/en/admin/finances/contacts/#{contact_id}")
else
nil
end
end
defp present?(nil), do: false
defp present?(s) when is_binary(s), do: String.trim(s) != ""
defp present?(_), do: false
# ---------------------------------------------------------------------------
# OIDC authentication
# ENV variables take priority; fallback to Settings from database.
# ---------------------------------------------------------------------------
@doc """
Returns the OIDC client ID. ENV first, then Settings.
"""
@spec oidc_client_id() :: String.t() | nil
def oidc_client_id do
env_or_setting("OIDC_CLIENT_ID", :oidc_client_id)
end
@doc """
Returns the OIDC provider base URL. ENV first, then Settings.
"""
@spec oidc_base_url() :: String.t() | nil
def oidc_base_url do
env_or_setting("OIDC_BASE_URL", :oidc_base_url)
end
@doc """
Returns the OIDC redirect URI. ENV first, then Settings.
"""
@spec oidc_redirect_uri() :: String.t() | nil
def oidc_redirect_uri do
env_or_setting("OIDC_REDIRECT_URI", :oidc_redirect_uri)
end
@doc """
Returns the OIDC client secret. ENV first, then Settings.
"""
@spec oidc_client_secret() :: String.t() | nil
def oidc_client_secret do
env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
end
@doc """
Returns the OIDC admin group name (for role sync). ENV first, then Settings.
"""
@spec oidc_admin_group_name() :: String.t() | nil
def oidc_admin_group_name do
env_or_setting("OIDC_ADMIN_GROUP_NAME", :oidc_admin_group_name)
end
@doc """
Returns the OIDC groups claim name (default "groups"). ENV first, then Settings.
"""
@spec oidc_groups_claim() :: String.t() | nil
def oidc_groups_claim do
case env_or_setting("OIDC_GROUPS_CLAIM", :oidc_groups_claim) do
nil -> "groups"
v -> v
end
end
@doc """
Returns true if any OIDC ENV variable is set (used to show hint in Settings UI).
"""
@spec oidc_env_configured?() :: boolean()
def oidc_env_configured? do
oidc_client_id_env_set?() or oidc_base_url_env_set?() or
oidc_redirect_uri_env_set?() or oidc_client_secret_env_set?() or
oidc_admin_group_name_env_set?() or oidc_groups_claim_env_set?() or
oidc_only_env_set?()
end
@doc """
Returns true when OIDC is configured and can be used for sign-in (client ID, base URL,
redirect URI, and client secret must be set). Used to show or hide the Single Sign-On button on the
sign-in page. Without client secret, the OIDC flow fails with MissingSecret; without redirect_uri,
the OIDC Plug crashes with URI.new(nil).
"""
@spec oidc_configured?() :: boolean()
def oidc_configured? do
id = oidc_client_id()
base = oidc_base_url()
secret = oidc_client_secret()
redirect = oidc_redirect_uri()
present = &(is_binary(&1) and String.trim(&1) != "")
present.(id) and present.(base) and present.(secret) and present.(redirect)
end
@doc """
Returns true when only OIDC sign-in should be shown (password login hidden).
ENV OIDC_ONLY first (true/1/yes vs false/0/no), then Settings.oidc_only.
Only has effect when OIDC is configured; when false or OIDC not configured, both password and OIDC are shown as usual.
"""
@spec oidc_only?() :: boolean()
def oidc_only? do
env_or_setting_bool("OIDC_ONLY", :oidc_only)
end
def oidc_client_id_env_set?, do: env_set?("OIDC_CLIENT_ID")
def oidc_base_url_env_set?, do: env_set?("OIDC_BASE_URL")
def oidc_redirect_uri_env_set?, do: env_set?("OIDC_REDIRECT_URI")
def oidc_client_secret_env_set?, do: env_set?("OIDC_CLIENT_SECRET")
def oidc_admin_group_name_env_set?, do: env_set?("OIDC_ADMIN_GROUP_NAME")
def oidc_groups_claim_env_set?, do: env_set?("OIDC_GROUPS_CLAIM")
def oidc_only_env_set?, do: env_set?("OIDC_ONLY")
end

View file

@ -10,6 +10,7 @@ defmodule Mv.Constants do
:join_date,
:exit_date,
:notes,
:country,
:city,
:street,
:house_number,
@ -27,8 +28,26 @@ defmodule Mv.Constants do
@email_validator_checks [:html_input, :pow]
# Member fields that are required when Vereinfacht integration is active (contact sync)
@vereinfacht_required_member_fields [:first_name, :last_name, :street, :postal_code, :city]
def member_fields, do: @member_fields
@doc """
Returns member fields that are always required when Vereinfacht integration is configured.
Used for validation, member form required indicators, and settings UI (checkbox disabled).
"""
def vereinfacht_required_member_fields, do: @vereinfacht_required_member_fields
@doc """
Returns whether the given member field is required by Vereinfacht when integration is active.
"""
def vereinfacht_required_field?(field) when is_atom(field),
do: field in @vereinfacht_required_member_fields
def vereinfacht_required_field?(_), do: false
@doc """
Returns the prefix used for custom field keys in field visibility maps.

View file

@ -17,15 +17,24 @@ defmodule Mv.Membership.Import.HeaderMapper do
## Member Field Mapping
Maps CSV headers to canonical member fields:
- `email` (required)
- `first_name` (optional)
- `last_name` (optional)
- `street` (optional)
- `postal_code` (optional)
- `city` (optional)
Maps CSV headers to canonical member fields (same as `Mv.Constants.member_fields()` for
importable attributes). All DB-backed member attributes can be imported.
Supports both English and German variants (e.g., "Email" / "E-Mail", "First Name" / "Vorname").
- `email` (required)
- `first_name`, `last_name` (optional)
- `join_date`, `exit_date` (optional, ISO-8601 date)
- `notes` (optional)
- `country`, `city`, `street`, `house_number`, `postal_code` (optional)
- `membership_fee_start_date` (optional, ISO-8601 date)
Supports English and German header variants (e.g. "Email" / "E-Mail", "Join Date" / "Beitrittsdatum").
## Fields not supported for import
- **membership_fee_status** Computed (calculation from membership fee cycles). Not stored;
cannot be set via CSV. Export can include it.
- **groups** Many-to-many relationship (through member_groups). Import would require
resolving group names/slugs to IDs and creating associations; not in current import scope.
## Custom Field Detection
@ -75,11 +84,37 @@ defmodule Mv.Membership.Import.HeaderMapper do
"nachname",
"familienname"
],
join_date: [
"join date",
"join_date",
"beitrittsdatum",
"beitritts-datum"
],
exit_date: [
"exit date",
"exit_date",
"austrittsdatum",
"austritts-datum"
],
notes: [
"notes",
"notizen",
"bemerkungen"
],
street: [
"street",
"address",
"strasse"
],
house_number: [
"house number",
"house_number",
"house no",
"hausnummer",
"nr",
"nr.",
"nummer"
],
postal_code: [
"postal code",
"postal_code",
@ -93,6 +128,18 @@ defmodule Mv.Membership.Import.HeaderMapper do
"town",
"stadt",
"ort"
],
country: [
"country",
"land",
"staat"
],
membership_fee_start_date: [
"membership fee start date",
"membership_fee_start_date",
"fee start",
"beitragsbeginn",
"beitrags-beginn"
]
}

View file

@ -549,9 +549,12 @@ defmodule Mv.Membership.Import.MemberCSV do
line_number,
actor
) do
# Convert empty strings to nil for date fields so Ash accepts them
member_attrs = sanitize_date_fields(trimmed_member_attrs)
# Create member with custom field values
member_attrs_with_cf =
trimmed_member_attrs
member_attrs
|> Map.put(:custom_field_values, custom_field_values)
# Only include custom_field_values if not empty
@ -793,6 +796,23 @@ defmodule Mv.Membership.Import.MemberCSV do
end)
end
# Converts empty strings to nil for date fields so Ash can accept them
@date_fields [:join_date, :exit_date, :membership_fee_start_date]
defp sanitize_date_fields(attrs) when is_map(attrs) do
Enum.reduce(@date_fields, attrs, fn field, acc ->
put_date_field(acc, field, Map.get(acc, field))
end)
end
defp put_date_field(acc, field, ""), do: Map.put(acc, field, nil)
defp put_date_field(acc, field, val) when is_binary(val) do
if String.trim(val) == "", do: Map.put(acc, field, nil), else: acc
end
defp put_date_field(acc, _field, _), do: acc
# Formats Ash errors into MemberCSV.Error structs
defp format_ash_error(%Ash.Error.Invalid{errors: errors}, line_number, email) do
# Try to find email-related errors first (for better error messages)

View file

@ -16,7 +16,7 @@ defmodule Mv.Membership.MemberExport do
alias MvWeb.MemberLive.Index.MembershipFeeStatus
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
["membership_fee_status"]
["membership_fee_type", "membership_fee_status", "groups"]
@computed_export_fields ["membership_fee_status"]
@computed_insert_after "membership_fee_start_date"
@custom_field_prefix Mv.Constants.custom_field_prefix()
@ -323,10 +323,14 @@ defmodule Mv.Membership.MemberExport do
|> Enum.filter(&(&1 in @domain_member_field_strings))
|> order_member_fields_like_table()
# final member_fields list (used for column specs order): table order + computed inserted
# Separate groups from other fields (groups is handled as a special field, not a member field)
groups_field = if "groups" in member_fields, do: ["groups"], else: []
# final member_fields list (used for column specs order): table order + fee type + computed + groups
ordered_member_fields =
selectable_member_fields
|> insert_computed_fields_like_table(computed_fields)
|> insert_fee_type_and_computed_fields_like_table(computed_fields, member_fields)
|> then(fn fields -> fields ++ groups_field end)
%{
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
@ -416,27 +420,52 @@ defmodule Mv.Membership.MemberExport do
table_order |> Enum.filter(&(&1 in fields))
end
defp insert_computed_fields_like_table(db_fields_ordered, computed_fields) do
# Insert membership_fee_status right after membership_fee_start_date (if both selected),
# otherwise append at the end of DB fields.
defp insert_fee_type_and_computed_fields_like_table(
db_fields_ordered,
computed_fields,
member_fields
) do
computed_fields = computed_fields || []
member_fields = member_fields || []
db_with_insert =
Enum.flat_map(db_fields_ordered, fn f ->
if f == @computed_insert_after and "membership_fee_status" in computed_fields do
[f, "membership_fee_status"]
else
[f]
end
expand_field_with_computed(f, member_fields, computed_fields)
end)
remaining =
computed_fields
|> Enum.reject(&(&1 in db_with_insert))
# If fee type is visible but start_date was not in the list, it won't be in db_with_insert
db_with_insert =
if "membership_fee_type" in member_fields and "membership_fee_type" not in db_with_insert do
db_with_insert ++ ["membership_fee_type"]
else
db_with_insert
end
remaining = Enum.reject(computed_fields, &(&1 in db_with_insert))
db_with_insert ++ remaining
end
# Insert membership_fee_type and membership_fee_status after membership_fee_start_date (table order).
defp expand_field_with_computed(f, member_fields, computed_fields) do
if f == @computed_insert_after do
extra = []
extra =
if "membership_fee_type" in member_fields,
do: extra ++ ["membership_fee_type"],
else: extra
extra =
if "membership_fee_status" in computed_fields,
do: extra ++ ["membership_fee_status"],
else: extra
[f] ++ extra
else
[f]
end
end
defp normalize_computed_fields(fields) when is_list(fields) do
fields
|> Enum.filter(&is_binary/1)

View file

@ -132,12 +132,20 @@ defmodule Mv.Membership.MemberExport.Build do
parsed.computed_fields != [] or
"membership_fee_status" in parsed.member_fields
need_groups = "groups" in parsed.member_fields
need_membership_fee_type =
"membership_fee_type" in parsed.member_fields or
parsed.sort_field == "membership_fee_type"
query =
Member
|> Ash.Query.new()
|> Ash.Query.select(select_fields)
|> load_custom_field_values_query(custom_field_ids_union)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|> maybe_load_groups(need_groups)
|> maybe_load_membership_fee_type(need_membership_fee_type)
query =
if parsed.selected_ids != [] do
@ -193,8 +201,10 @@ defmodule Mv.Membership.MemberExport.Build do
defp sort_members_in_memory(members, field, order) when is_binary(field) do
field_atom = String.to_existing_atom(field)
if field_atom in Mv.Constants.member_fields() do
sort_by_field(members, field_atom, order)
if field_atom in Mv.Constants.member_fields() or field_atom == :membership_fee_type do
key_fn = sort_key_fn_for_field(field_atom)
compare_fn = build_compare_fn(order)
Enum.sort_by(members, key_fn, compare_fn)
else
members
end
@ -204,13 +214,17 @@ defmodule Mv.Membership.MemberExport.Build do
defp sort_members_in_memory(members, _field, _order), do: members
defp sort_by_field(members, field_atom, order) do
key_fn = fn member -> Map.get(member, field_atom) end
compare_fn = build_compare_fn(order)
Enum.sort_by(members, key_fn, compare_fn)
defp sort_key_fn_for_field(:membership_fee_type) do
fn member ->
case Map.get(member, :membership_fee_type) do
nil -> nil
rel -> Map.get(rel, :name)
end
end
end
defp sort_key_fn_for_field(field_atom), do: fn member -> Map.get(member, field_atom) end
defp build_compare_fn("asc"), do: fn a, b -> a <= b end
defp build_compare_fn("desc"), do: fn a, b -> b <= a end
defp build_compare_fn(_), do: fn _a, _b -> true end
@ -241,30 +255,65 @@ defmodule Mv.Membership.MemberExport.Build do
defp maybe_sort(query, _field, nil), do: {query, false}
defp maybe_sort(query, field, order) when is_binary(field) do
if custom_field_sort?(field) do
{query, true}
else
field_atom = String.to_existing_atom(field)
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
else
{query, false}
end
cond do
field == "groups" -> {query, true}
field == "membership_fee_type" -> apply_fee_type_sort(query, order)
custom_field_sort?(field) -> {query, true}
true -> apply_standard_member_sort(query, field, order)
end
rescue
ArgumentError -> {query, false}
end
defp apply_fee_type_sort(query, order) do
order_atom = if order == "desc", do: :desc, else: :asc
{Ash.Query.sort(query, [{"membership_fee_type.name", order_atom}]), false}
end
defp apply_standard_member_sort(query, field, order) do
field_atom = String.to_existing_atom(field)
sortable =
field_atom in (Mv.Constants.member_fields() -- [:notes]) or
field_atom == :membership_fee_type
if sortable do
order_atom = if order == "desc", do: :desc, else: :asc
sort_field =
if field_atom == :membership_fee_type,
do: {"membership_fee_type.name", order_atom},
else: {field_atom, order_atom}
{Ash.Query.sort(query, [sort_field]), false}
else
{query, false}
end
end
defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [],
do: []
defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do
if field == "groups" do
sort_members_by_groups_export(members, order)
else
sort_by_custom_field_value(members, field, order, custom_fields)
end
end
defp sort_by_custom_field_value(members, field, order, custom_fields) do
id_str = String.trim_leading(field, @custom_field_prefix)
custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
if is_nil(custom_field), do: members
if is_nil(custom_field) do
members
else
sort_members_with_custom_field(members, custom_field, order)
end
end
defp sort_members_with_custom_field(members, custom_field, order) do
key_fn = fn member ->
cfv = find_cfv(member, custom_field)
raw = if cfv, do: cfv.value, else: nil
@ -277,6 +326,26 @@ defmodule Mv.Membership.MemberExport.Build do
|> Enum.map(fn {m, _} -> m end)
end
defp sort_members_by_groups_export(members, order) do
# Members with groups first, then by first group name alphabetically (min = first by sort order)
# Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2
first_group_name = fn member ->
(member.groups || [])
|> Enum.map(& &1.name)
|> Enum.min(fn -> nil end)
end
members
|> Enum.sort_by(fn member ->
name = first_group_name.(member)
# Nil (no groups) sorts last in asc, first in desc
{name == nil, name || ""}
end)
|> then(fn list ->
if order == "desc", do: Enum.reverse(list), else: list
end)
end
defp find_cfv(member, custom_field) do
(member.custom_field_values || [])
|> Enum.find(fn cfv ->
@ -294,6 +363,19 @@ defmodule Mv.Membership.MemberExport.Build do
MembershipFeeStatus.load_cycles_for_members(query, show_current)
end
defp maybe_load_groups(query, false), do: query
defp maybe_load_groups(query, true) do
# Load groups with id and name only (for export formatting)
Ash.Query.load(query, groups: [:id, :name])
end
defp maybe_load_membership_fee_type(query, false), do: query
defp maybe_load_membership_fee_type(query, true) do
Ash.Query.load(query, membership_fee_type: [:id, :name])
end
defp apply_cycle_status_filter(members, nil, _show_current), do: members
defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do
@ -343,6 +425,32 @@ defmodule Mv.Membership.MemberExport.Build do
}
end)
membership_fee_type_col =
if "membership_fee_type" in parsed.member_fields do
[
%{
key: :membership_fee_type,
kind: :membership_fee_type,
label: label_fn.(:membership_fee_type)
}
]
else
[]
end
groups_col =
if "groups" in parsed.member_fields do
[
%{
key: :groups,
kind: :groups,
label: label_fn.(:groups)
}
]
else
[]
end
custom_cols =
parsed.custom_field_ids
|> Enum.map(fn id ->
@ -361,7 +469,8 @@ defmodule Mv.Membership.MemberExport.Build do
end)
|> Enum.reject(&is_nil/1)
member_cols ++ computed_cols ++ custom_cols
# Table order: ... membership_fee_start_date, membership_fee_type, membership_fee_status, groups, custom
member_cols ++ membership_fee_type_col ++ computed_cols ++ groups_col ++ custom_cols
end
defp build_rows(members, columns, custom_fields_by_id) do
@ -391,6 +500,22 @@ defmodule Mv.Membership.MemberExport.Build do
if is_binary(value), do: value, else: ""
end
defp cell_value(
member,
%{kind: :membership_fee_type, key: :membership_fee_type},
_custom_fields_by_id
) do
case Map.get(member, :membership_fee_type) do
%{name: name} when is_binary(name) -> name
_ -> ""
end
end
defp cell_value(member, %{kind: :groups, key: :groups}, _custom_fields_by_id) do
groups = Map.get(member, :groups) || []
format_groups(groups)
end
defp key_to_atom(k) when is_atom(k), do: k
defp key_to_atom(k) when is_binary(k) do
@ -424,6 +549,15 @@ defmodule Mv.Membership.MemberExport.Build do
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
defp format_member_value(value), do: to_string(value)
defp format_groups([]), do: ""
defp format_groups(groups) when is_list(groups) do
groups
|> Enum.map(fn group -> Map.get(group, :name) || "" end)
|> Enum.reject(&(&1 == ""))
|> Enum.join(", ")
end
defp build_meta(members) do
%{
generated_at: DateTime.utc_now() |> DateTime.to_iso8601(),

View file

@ -59,6 +59,18 @@ defmodule Mv.Membership.MembersCSV do
if is_binary(value), do: value, else: ""
end
defp cell_value(member, %{kind: :membership_fee_type, key: :membership_fee_type}) do
case Map.get(member, :membership_fee_type) do
%{name: name} when is_binary(name) -> name
_ -> ""
end
end
defp cell_value(member, %{kind: :groups, key: :groups}) do
groups = Map.get(member, :groups) || []
format_groups(groups)
end
defp key_to_atom(k) when is_atom(k), do: k
defp key_to_atom(k) when is_binary(k) do
@ -97,4 +109,13 @@ defmodule Mv.Membership.MembersCSV do
defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
defp format_member_value(value), do: to_string(value)
defp format_groups([]), do: ""
defp format_groups(groups) when is_list(groups) do
groups
|> Enum.map(fn group -> Map.get(group, :name) || "" end)
|> Enum.reject(&(&1 == ""))
|> Enum.join(", ")
end
end

View file

@ -2,7 +2,7 @@ defmodule Mv.OidcRoleSync do
@moduledoc """
Syncs user role from OIDC user_info (e.g. groups claim Admin role).
Used after OIDC registration (register_with_rauthy) and on sign-in so that
Used after OIDC registration (register_with_oidc) and on sign-in so that
users in the configured admin group get the Admin role; others get Mitglied.
Configure via OIDC_ADMIN_GROUP_NAME and OIDC_GROUPS_CLAIM (see OidcRoleSyncConfig).

View file

@ -2,23 +2,19 @@ defmodule Mv.OidcRoleSyncConfig do
@moduledoc """
Runtime configuration for OIDC group role sync (e.g. admin group Admin role).
Reads from Application config `:mv, :oidc_role_sync`:
- `:admin_group_name` OIDC group name that maps to Admin role (optional; when nil, no sync).
- `:groups_claim` JWT/user_info claim name for groups (default: `"groups"`).
Reads from Mv.Config (ENV first, then Settings):
- `oidc_admin_group_name/0` OIDC group name that maps to Admin role (optional; when nil, no sync).
- `oidc_groups_claim/0` JWT/user_info claim name for groups (default: `"groups"`).
Set via ENV in production: OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM (see config/runtime.exs).
Set via ENV: OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM; or via Settings (Basic settings OIDC).
"""
@doc "Returns the OIDC group name that maps to Admin role, or nil if not configured."
def oidc_admin_group_name do
get(:admin_group_name)
Mv.Config.oidc_admin_group_name()
end
@doc "Returns the JWT/user_info claim name for groups; defaults to \"groups\"."
def oidc_groups_claim do
get(:groups_claim) || "groups"
end
defp get(key) do
Application.get_env(:mv, :oidc_role_sync, []) |> Keyword.get(key)
Mv.Config.oidc_groups_claim() || "groups"
end
end

View file

@ -7,59 +7,66 @@ defmodule Mv.Secrets do
particularly for OIDC (Rauthy) authentication.
## Configuration Source
Secrets are read from the `:rauthy` key in the application configuration,
which is typically set in `config/runtime.exs` from environment variables:
- `OIDC_CLIENT_ID`
- `OIDC_CLIENT_SECRET`
- `OIDC_BASE_URL`
- `OIDC_REDIRECT_URI`
Secrets are read via `Mv.Config` which prefers environment variables and
falls back to Settings from the database:
- OIDC_CLIENT_ID / settings.oidc_client_id
- OIDC_CLIENT_SECRET / settings.oidc_client_secret
- OIDC_BASE_URL / settings.oidc_base_url
- OIDC_REDIRECT_URI / settings.oidc_redirect_uri
## Usage
This module is automatically called by AshAuthentication when resolving
secrets for the User resource's OIDC strategy.
When a value is nil, returns `{:error, MissingSecret}` so that AshAuthentication
does not crash (e.g. URI.new(nil)) and can redirect to sign-in with an error.
"""
use AshAuthentication.Secret
alias AshAuthentication.Errors.MissingSecret
def secret_for(
[:authentication, :strategies, :rauthy, :client_id],
Mv.Accounts.User,
[:authentication, :strategies, :oidc, :client_id],
resource,
_opts,
_meth
) do
get_config(:client_id)
secret_or_error(Mv.Config.oidc_client_id(), resource, :client_id)
end
def secret_for(
[:authentication, :strategies, :rauthy, :redirect_uri],
Mv.Accounts.User,
[:authentication, :strategies, :oidc, :redirect_uri],
resource,
_opts,
_meth
) do
get_config(:redirect_uri)
secret_or_error(Mv.Config.oidc_redirect_uri(), resource, :redirect_uri)
end
def secret_for(
[:authentication, :strategies, :rauthy, :client_secret],
Mv.Accounts.User,
[:authentication, :strategies, :oidc, :client_secret],
resource,
_opts,
_meth
) do
get_config(:client_secret)
secret_or_error(Mv.Config.oidc_client_secret(), resource, :client_secret)
end
def secret_for(
[:authentication, :strategies, :rauthy, :base_url],
Mv.Accounts.User,
[:authentication, :strategies, :oidc, :base_url],
resource,
_opts,
_meth
) do
get_config(:base_url)
secret_or_error(Mv.Config.oidc_base_url(), resource, :base_url)
end
defp get_config(key) do
:mv
|> Application.fetch_env!(:rauthy)
|> Keyword.fetch!(key)
|> then(&{:ok, &1})
defp secret_or_error(nil, resource, key) do
path = [:authentication, :strategies, :oidc, key]
{:error, MissingSecret.exception(path: path, resource: resource)}
end
defp secret_or_error(value, resource, key) when is_binary(value) do
if String.trim(value) == "" do
secret_or_error(nil, resource, key)
else
{:ok, value}
end
end
end

View file

@ -0,0 +1,91 @@
defmodule Mv.Vereinfacht.Changes.SyncContact do
@moduledoc """
Syncs a member to Vereinfacht as a finance contact after create/update.
- If the member has no `vereinfacht_contact_id`, creates a contact via API and saves the ID.
- If the member already has an ID, updates the contact via API.
Runs in `after_transaction` so the member is persisted first. API failures are logged
but do not block the member operation. Requires Vereinfacht to be configured
(Mv.Config.vereinfacht_configured?/0).
Only runs when relevant data changed: on create always; on update only when
first_name, last_name, email, street, house_number, postal_code, or city changed,
or when the member has no vereinfacht_contact_id yet (to avoid unnecessary API calls).
"""
use Ash.Resource.Change
require Logger
@synced_attributes [
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city
]
@impl true
def change(changeset, _opts, _context) do
if Mv.Config.vereinfacht_configured?() and sync_relevant?(changeset) do
Ash.Changeset.after_transaction(changeset, &sync_after_transaction/2)
else
changeset
end
end
defp sync_relevant?(changeset) do
case changeset.action_type do
:create -> true
:update -> relevant_update?(changeset)
_ -> false
end
end
defp relevant_update?(changeset) do
any_synced_attr_changed? =
Enum.any?(@synced_attributes, &Ash.Changeset.changing_attribute?(changeset, &1))
record = changeset.data
no_contact_id_yet? = record && blank_contact_id?(record.vereinfacht_contact_id)
any_synced_attr_changed? or no_contact_id_yet?
end
defp blank_contact_id?(nil), do: true
defp blank_contact_id?(""), do: true
defp blank_contact_id?(s) when is_binary(s), do: String.trim(s) == ""
defp blank_contact_id?(_), do: false
# Ash calls after_transaction with (changeset, result) only - 2 args.
defp sync_after_transaction(_changeset, {:ok, member}) do
case Mv.Vereinfacht.sync_member(member) do
:ok ->
Mv.Vereinfacht.SyncFlash.store(to_string(member.id), :ok, "Synced to Vereinfacht.")
{:ok, member}
{:ok, member_updated} ->
Mv.Vereinfacht.SyncFlash.store(
to_string(member_updated.id),
:ok,
"Synced to Vereinfacht."
)
{:ok, member_updated}
{:error, reason} ->
Logger.warning("Vereinfacht sync failed for member #{member.id}: #{inspect(reason)}")
Mv.Vereinfacht.SyncFlash.store(
to_string(member.id),
:warning,
Mv.Vereinfacht.format_error(reason)
)
{:ok, member}
end
end
defp sync_after_transaction(_changeset, error), do: error
end

View file

@ -0,0 +1,71 @@
defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do
@moduledoc """
Syncs the linked Member to Vereinfacht after a User action that may have updated
the member's email via Ecto (e.g. User email change → SyncUserEmailToMember).
Attach to any User action that uses SyncUserEmailToMember. After the transaction
commits, if the user has a linked member and Vereinfacht is configured, syncs
that member to the API. Failures are logged but do not affect the User result.
"""
use Ash.Resource.Change
require Logger
alias Mv.Membership.Member
alias Mv.Membership
alias Mv.Helpers.SystemActor
alias Mv.Helpers
@impl true
def change(changeset, _opts, _context) do
if Mv.Config.vereinfacht_configured?() and relevant_change?(changeset) do
Ash.Changeset.after_transaction(changeset, &sync_linked_member_after_transaction/2)
else
changeset
end
end
# Only sync when something that affects the linked member's data actually changed
# (email sync or member link), to avoid unnecessary API calls on every user update.
defp relevant_change?(changeset) do
Ash.Changeset.changing_attribute?(changeset, :email) or
Ash.Changeset.changing_relationship?(changeset, :member)
end
defp sync_linked_member_after_transaction(_changeset, {:ok, user}) do
case load_linked_member(user) do
nil ->
{:ok, user}
member ->
case Mv.Vereinfacht.sync_member(member) do
:ok ->
{:ok, user}
{:ok, _} ->
{:ok, user}
{:error, reason} ->
Logger.warning(
"Vereinfacht sync failed for member #{member.id} (linked to user #{user.id}): #{inspect(reason)}"
)
{:ok, user}
end
end
end
defp sync_linked_member_after_transaction(_changeset, result), do: result
defp load_linked_member(%{member_id: nil}), do: nil
defp load_linked_member(%{member_id: ""}), do: nil
defp load_linked_member(user) do
actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(actor)
case Ash.get(Member, user.member_id, [domain: Membership] ++ opts) do
{:ok, %Member{} = member} -> member
_ -> nil
end
end
end

View file

@ -0,0 +1,423 @@
defmodule Mv.Vereinfacht.Client do
@moduledoc """
HTTP client for the Vereinfacht accounting software JSON:API.
Creates and updates finance contacts. Uses Bearer token authentication and
requires club ID for multi-tenancy. Configuration via ENV or Settings
(see Mv.Config).
"""
require Logger
@content_type "application/vnd.api+json"
@doc """
Tests the connection to the Vereinfacht API with the given credentials.
Makes a lightweight `GET /finance-contacts?page[size]=1` request to verify
that the API URL, API key, and club ID are valid and reachable.
## Returns
- `{:ok, :connected}` credentials are valid (HTTP 200)
- `{:error, :not_configured}` any parameter is nil or blank
- `{:error, {:http, status, message}}` API returned an error (e.g. 401, 403)
- `{:error, {:request_failed, reason}}` network/transport error
## Examples
iex> test_connection("https://api.example.com/api/v1", "token", "2")
{:ok, :connected}
iex> test_connection(nil, "token", "2")
{:error, :not_configured}
"""
@spec test_connection(String.t() | nil, String.t() | nil, String.t() | nil) ::
{:ok, :connected} | {:error, term()}
def test_connection(api_url, api_key, club_id) do
if blank?(api_url) or blank?(api_key) or blank?(club_id) do
{:error, :not_configured}
else
url =
api_url
|> String.trim_trailing("/")
|> then(&"#{&1}/finance-contacts?page[size]=1")
case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do
{:ok, %{status: 200}} ->
{:ok, :connected}
{:ok, %{status: status, body: body}} ->
{:error, {:http, status, extract_error_message(body)}}
{:error, reason} ->
{:error, {:request_failed, reason}}
end
end
end
defp blank?(nil), do: true
defp blank?(s) when is_binary(s), do: String.trim(s) == ""
defp blank?(_), do: true
@doc """
Creates a finance contact in Vereinfacht for the given member.
Returns the contact ID on success. Does not update the member record;
the caller (e.g. SyncContact change) must persist `vereinfacht_contact_id`.
## Options
- None; URL, API key, and club ID are read from Mv.Config.
## Examples
iex> create_contact(member)
{:ok, "242"}
iex> create_contact(member)
{:error, {:http, 401, "Unauthenticated."}}
"""
@spec create_contact(struct()) :: {:ok, String.t()} | {:error, term()}
def create_contact(member) do
base_url = base_url()
api_key = api_key()
club_id = club_id()
if is_nil(base_url) or is_nil(api_key) or is_nil(club_id) do
{:error, :not_configured}
else
body = build_create_body(member, club_id)
url = base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts")
post_and_parse_contact(url, body, api_key)
end
end
@sync_timeout_ms 5_000
# In test, skip retries so sync fails fast when no API is running (avoids log spam and long waits).
defp req_http_options do
opts = [receive_timeout: @sync_timeout_ms]
if Mix.env() == :test, do: [retry: false] ++ opts, else: opts
end
defp post_and_parse_contact(url, body, api_key) do
encoded_body = Jason.encode!(body)
case Req.post(url, [body: encoded_body, headers: headers(api_key)] ++ req_http_options()) do
{:ok, %{status: 201, body: resp_body}} ->
case get_contact_id_from_response(resp_body) do
nil -> {:error, {:invalid_response, resp_body}}
id -> {:ok, id}
end
{:ok, %{status: status, body: resp_body}} ->
{:error, {:http, status, extract_error_message(resp_body)}}
{:error, reason} ->
{:error, {:request_failed, reason}}
end
end
@doc """
Updates an existing finance contact in Vereinfacht.
Only sends attributes that are typically synced from the member (name, email,
address fields). Returns the same contact_id on success.
## Examples
iex> update_contact("242", member)
{:ok, "242"}
iex> update_contact("242", member)
{:error, {:http, 404, "Not Found"}}
"""
@spec update_contact(String.t(), struct()) :: {:ok, String.t()} | {:error, term()}
def update_contact(contact_id, member) when is_binary(contact_id) do
base_url = base_url()
api_key = api_key()
if is_nil(base_url) or is_nil(api_key) do
{:error, :not_configured}
else
body = build_update_body(contact_id, member)
encoded_body = Jason.encode!(body)
url =
base_url
|> String.trim_trailing("/")
|> then(&"#{&1}/finance-contacts/#{contact_id}")
case Req.patch(
url,
[
body: encoded_body,
headers: headers(api_key)
] ++ req_http_options()
) do
{:ok, %{status: 200, body: _resp_body}} ->
{:ok, contact_id}
{:ok, %{status: status, body: body}} ->
{:error, {:http, status, extract_error_message(body)}}
{:error, reason} ->
{:error, {:request_failed, reason}}
end
end
end
@doc """
Finds a finance contact by email (GET /finance-contacts, then match in response).
The Vereinfacht API does not allow filter by email on this endpoint, so we
fetch the first page and find the contact client-side. Returns {:ok, contact_id}
if a contact with that email exists, {:error, :not_found} if none, or
{:error, reason} on API/network failure. Used before create for idempotency.
"""
@spec find_contact_by_email(String.t()) :: {:ok, String.t()} | {:error, :not_found | term()}
def find_contact_by_email(email) when is_binary(email) do
if is_nil(base_url()) or is_nil(api_key()) or is_nil(club_id()) do
{:error, :not_configured}
else
do_find_contact_by_email(email)
end
end
@find_contact_page_size 100
@find_contact_max_pages 100
defp do_find_contact_by_email(email) do
normalized = String.trim(email) |> String.downcase()
do_find_contact_by_email_page(1, normalized)
end
defp do_find_contact_by_email_page(page, _normalized) when page > @find_contact_max_pages do
{:error, :not_found}
end
defp do_find_contact_by_email_page(page, normalized) do
base = base_url() |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts")
url = base <> "?page[size]=#{@find_contact_page_size}&page[number]=#{page}"
case Req.get(url, [headers: headers(api_key())] ++ req_http_options()) do
{:ok, %{status: 200, body: body}} when is_map(body) ->
handle_find_contact_page_response(body, page, normalized)
{:ok, %{status: status, body: body}} ->
{:error, {:http, status, extract_error_message(body)}}
{:error, reason} ->
{:error, {:request_failed, reason}}
end
end
defp handle_find_contact_page_response(body, page, normalized) do
case find_contact_id_by_email_in_list(body, normalized) do
id when is_binary(id) -> {:ok, id}
nil -> maybe_find_contact_next_page(body, page, normalized)
end
end
defp maybe_find_contact_next_page(body, page, normalized) do
data = Map.get(body, "data") || []
if length(data) < @find_contact_page_size,
do: {:error, :not_found},
else: do_find_contact_by_email_page(page + 1, normalized)
end
defp find_contact_id_by_email_in_list(%{"data" => list}, normalized) when is_list(list) do
Enum.find_value(list, fn
%{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => true}}
when is_binary(att_email) ->
if att_email |> String.trim() |> String.downcase() == normalized do
normalize_contact_id(id)
else
nil
end
%{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => "true"}}
when is_binary(att_email) ->
if att_email |> String.trim() |> String.downcase() == normalized do
normalize_contact_id(id)
else
nil
end
%{"id" => _id, "attributes" => _} ->
nil
_ ->
nil
end)
end
defp find_contact_id_by_email_in_list(_, _), do: nil
defp normalize_contact_id(id) when is_binary(id), do: id
defp normalize_contact_id(id) when is_integer(id), do: to_string(id)
defp normalize_contact_id(_), do: nil
@doc """
Fetches a single finance contact from Vereinfacht (GET /finance-contacts/:id).
Returns the full response body (decoded JSON) for debugging/display.
"""
@spec get_contact(String.t()) :: {:ok, map()} | {:error, term()}
def get_contact(contact_id) when is_binary(contact_id) do
fetch_contact(contact_id, [])
end
@doc """
Fetches a finance contact with receipts (GET /finance-contacts/:id?include=receipts).
Returns {:ok, receipts} where receipts is a list of maps with :id and :attributes
(and optional :type) for each receipt, or {:error, reason}.
"""
@spec get_contact_with_receipts(String.t()) :: {:ok, [map()]} | {:error, term()}
def get_contact_with_receipts(contact_id) when is_binary(contact_id) do
case fetch_contact(contact_id, include: "receipts") do
{:ok, body} -> {:ok, extract_receipts_from_response(body)}
{:error, _} = err -> err
end
end
defp fetch_contact(contact_id, query_params) do
base_url = base_url()
api_key = api_key()
if is_nil(base_url) or is_nil(api_key) do
{:error, :not_configured}
else
path =
base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}")
url = build_url_with_params(path, query_params)
case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do
{:ok, %{status: 200, body: body}} when is_map(body) ->
{:ok, body}
{:ok, %{status: status, body: body}} ->
{:error, {:http, status, extract_error_message(body)}}
{:error, reason} ->
{:error, {:request_failed, reason}}
end
end
end
defp build_url_with_params(base, []), do: base
defp build_url_with_params(base, include: value) do
sep = if String.contains?(base, "?"), do: "&", else: "?"
base <> sep <> "include=" <> URI.encode(value, &URI.char_unreserved?/1)
end
# Allowlist of receipt attribute keys we expose (avoids String.to_atom on arbitrary API input / DoS).
@receipt_attr_allowlist ~w[amount bookingDate createdAt receiptType referenceNumber status updatedAt]a
defp extract_receipts_from_response(%{"included" => included}) when is_list(included) do
included
|> Enum.filter(&match?(%{"type" => "receipts"}, &1))
|> Enum.map(fn %{"id" => id, "attributes" => attrs} = r ->
Map.merge(%{id: id, type: r["type"]}, receipt_attrs_allowlist(attrs || %{}))
end)
end
defp extract_receipts_from_response(_), do: []
defp receipt_attrs_allowlist(attrs) when is_map(attrs) do
Map.new(@receipt_attr_allowlist, fn key ->
str_key = to_string(key)
{key, Map.get(attrs, str_key)}
end)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
end
defp base_url, do: Mv.Config.vereinfacht_api_url()
defp api_key, do: Mv.Config.vereinfacht_api_key()
defp club_id, do: Mv.Config.vereinfacht_club_id()
defp headers(api_key) do
[
{"Accept", @content_type},
{"Content-Type", @content_type},
{"Authorization", "Bearer #{api_key}"}
]
end
defp build_create_body(member, club_id) do
attributes = member_to_attributes(member)
%{
"data" => %{
"type" => "finance-contacts",
"attributes" => attributes,
"relationships" => %{
"club" => %{
"data" => %{"type" => "clubs", "id" => club_id}
}
}
}
}
end
defp build_update_body(contact_id, member) do
attributes = member_to_attributes(member)
%{
"data" => %{
"type" => "finance-contacts",
"id" => contact_id,
"attributes" => attributes
}
}
end
defp member_to_attributes(member) do
address =
[member |> Map.get(:street), member |> Map.get(:house_number)]
|> Enum.reject(&is_nil/1)
|> Enum.map_join(" ", &to_string/1)
|> then(fn s -> if s == "", do: nil, else: s end)
%{}
|> put_attr("lastName", member |> Map.get(:last_name))
|> put_attr("firstName", member |> Map.get(:first_name))
|> put_attr("email", member |> Map.get(:email))
|> put_attr("address", address)
|> put_attr("zipCode", member |> Map.get(:postal_code))
|> put_attr("city", member |> Map.get(:city))
|> Map.put("contactType", "person")
|> Map.put("isExternal", true)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
end
defp put_attr(acc, _key, nil), do: acc
defp put_attr(acc, key, value), do: Map.put(acc, key, to_string(value))
defp get_contact_id_from_response(%{"data" => %{"id" => id}}) when is_binary(id), do: id
defp get_contact_id_from_response(%{"data" => %{"id" => id}}) when is_integer(id),
do: to_string(id)
defp get_contact_id_from_response(_), do: nil
defp extract_error_message(%{"errors" => [%{"detail" => d} | _]}) when is_binary(d), do: d
defp extract_error_message(%{"errors" => [%{"title" => t} | _]}) when is_binary(t), do: t
defp extract_error_message(body) when is_map(body), do: inspect(body)
defp extract_error_message(body) when is_binary(body) do
trimmed = String.trim(body)
if String.starts_with?(trimmed, "<") do
:html_response
else
trimmed
end
end
defp extract_error_message(other), do: inspect(other)
end

View file

@ -0,0 +1,46 @@
defmodule Mv.Vereinfacht.SyncFlash do
@moduledoc """
Short-lived store for Vereinfacht sync results so the UI can show them after save.
The SyncContact change runs in after_transaction and cannot access the LiveView
socket. This module stores a message keyed by member_id; the form LiveView
calls `take/1` after a successful save and displays the message in flash.
"""
@table :vereinfacht_sync_flash
@doc """
Stores a sync result for the given member. Overwrites any previous message.
- `:ok` - Sync succeeded (optional user message).
- `:warning` - Sync failed; message should be shown as a warning.
"""
@spec store(String.t(), :ok | :warning, String.t()) :: :ok
def store(member_id, kind, message) when is_binary(member_id) do
:ets.insert(@table, {member_id, {kind, message}})
:ok
end
@doc """
Takes and removes the stored sync message for the given member.
Returns `{kind, message}` if present, otherwise `nil`.
"""
@spec take(String.t()) :: {:ok | :warning, String.t()} | nil
def take(member_id) when is_binary(member_id) do
case :ets.take(@table, member_id) do
[{^member_id, value}] -> value
[] -> nil
end
end
@doc false
def create_table! do
# :public so any process can write (SyncContact runs in LiveView/Ash transaction process,
# not the process that created the table). :protected would restrict writes to the creating process.
if :ets.whereis(@table) == :undefined do
:ets.new(@table, [:set, :public, :named_table])
end
:ok
end
end

View file

@ -0,0 +1,186 @@
defmodule Mv.Vereinfacht do
@moduledoc """
Business logic for Vereinfacht accounting software integration.
- `sync_member/1` Sync a single member to the API (create or update contact).
Used by Member create/update (SyncContact) and by User actions that update
the linked member's email via Ecto (e.g. user email change).
- `sync_members_without_contact/0` Bulk sync of members without a contact ID.
"""
require Ash.Query
import Ash.Expr
alias Mv.Vereinfacht.Client
alias Mv.Membership.Member
alias Mv.Helpers.SystemActor
alias Mv.Helpers
@doc """
Tests the connection to the Vereinfacht API using the current configuration.
Delegates to `Mv.Vereinfacht.Client.test_connection/3` with the values from
`Mv.Config` (ENV variables take priority over database settings).
## Returns
- `{:ok, :connected}` credentials are valid and API is reachable
- `{:error, :not_configured}` URL, API key or club ID is missing
- `{:error, {:http, status, message}}` API returned an error (e.g. 401, 403)
- `{:error, {:request_failed, reason}}` network/transport error
"""
@spec test_connection() :: {:ok, :connected} | {:error, term()}
def test_connection do
Client.test_connection(
Mv.Config.vereinfacht_api_url(),
Mv.Config.vereinfacht_api_key(),
Mv.Config.vereinfacht_club_id()
)
end
@doc """
Syncs a single member to Vereinfacht (create or update finance contact).
If the member has no `vereinfacht_contact_id`, creates a contact and updates
the member with the new ID. If they already have an ID, updates the contact.
Uses system actor for any Ash update. Does nothing if Vereinfacht is not configured.
Returns:
- `:ok` Contact was updated.
- `{:ok, member}` Contact was created and member was updated with the new ID.
- `{:error, reason}` API or update failed.
"""
@spec sync_member(struct()) :: :ok | {:ok, struct()} | {:error, term()}
def sync_member(member) do
if Mv.Config.vereinfacht_configured?() do
do_sync_member(member)
else
:ok
end
end
defp do_sync_member(member) do
if present_contact_id?(member.vereinfacht_contact_id) do
sync_existing_contact(member)
else
ensure_contact_then_save(member)
end
end
defp sync_existing_contact(member) do
case Client.update_contact(member.vereinfacht_contact_id, member) do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp ensure_contact_then_save(member) do
case get_or_create_contact_id(member) do
{:ok, contact_id} -> save_contact_id(member, contact_id)
{:error, _} = err -> err
end
end
# Before create: find by email to avoid duplicate contacts (idempotency).
# When an existing contact is found, update it with current member data.
defp get_or_create_contact_id(member) do
email = member |> Map.get(:email) |> to_string() |> String.trim()
if email == "" do
Client.create_contact(member)
else
case Client.find_contact_by_email(email) do
{:ok, existing_id} -> update_existing_contact_and_return_id(existing_id, member)
{:error, :not_found} -> Client.create_contact(member)
{:error, _} = err -> err
end
end
end
defp update_existing_contact_and_return_id(contact_id, member) do
case Client.update_contact(contact_id, member) do
{:ok, _} -> {:ok, contact_id}
{:error, _} = err -> err
end
end
defp save_contact_id(member, contact_id) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
case Ash.update(member, %{vereinfacht_contact_id: contact_id}, [
{:action, :set_vereinfacht_contact_id} | opts
]) do
{:ok, updated} -> {:ok, updated}
{:error, reason} -> {:error, reason}
end
end
defp present_contact_id?(nil), do: false
defp present_contact_id?(""), do: false
defp present_contact_id?(s) when is_binary(s), do: String.trim(s) != ""
defp present_contact_id?(_), do: false
@doc """
Formats an API/request error reason into a short user-facing message.
Used by SyncContact (flash) and GlobalSettingsLive (sync result list).
"""
@spec format_error(term()) :: String.t()
def format_error({:http, _status, detail}) when is_binary(detail), do: "Vereinfacht: " <> detail
def format_error({:http, status, _}), do: "Vereinfacht: API error (HTTP #{status})."
def format_error({:request_failed, _}),
do: "Vereinfacht: Request failed (e.g. connection error)."
def format_error({:invalid_response, _}), do: "Vereinfacht: Invalid API response."
def format_error(other), do: "Vereinfacht: " <> inspect(other)
@doc """
Creates Vereinfacht contacts for all members that do not yet have a
`vereinfacht_contact_id`. Uses system actor for reads and updates.
Returns `{:ok, %{synced: count, errors: list}}` where errors is a list of
`{member_id, reason}`. Does nothing if Vereinfacht is not configured.
"""
@spec sync_members_without_contact() ::
{:ok, %{synced: non_neg_integer(), errors: [{String.t(), term()}]}}
| {:error, :not_configured}
def sync_members_without_contact do
if Mv.Config.vereinfacht_configured?() do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
query =
Member
|> Ash.Query.filter(
expr(is_nil(^ref(:vereinfacht_contact_id)) or ^ref(:vereinfacht_contact_id) == "")
)
case Ash.read(query, opts) do
{:ok, members} ->
do_sync_members(members, opts)
{:error, _} = err ->
err
end
else
{:error, :not_configured}
end
end
defp do_sync_members(members, opts) do
{synced, errors} =
Enum.reduce(members, {0, []}, fn member, {acc_synced, acc_errors} ->
{inc, new_errors} = sync_one_member(member, opts)
{acc_synced + inc, acc_errors ++ new_errors}
end)
{:ok, %{synced: synced, errors: errors}}
end
defp sync_one_member(member, _opts) do
case sync_member(member) do
:ok -> {1, []}
{:ok, _} -> {1, []}
{:error, reason} -> {0, [{member.id, reason}]}
end
end
end

View file

@ -38,11 +38,16 @@ defmodule MvWeb.AuthOverrides do
set :image_url, nil
end
# Translate the or in the horizontal rule to German
# 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,
Gettext.with_locale(MvWeb.Gettext, "de", fn ->
Gettext.gettext(MvWeb.Gettext, "or")
end)
set :text, dgettext("auth", "or")
end
# 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

View file

@ -448,6 +448,8 @@ defmodule MvWeb.CoreComponents do
end
def input(%{type: "select"} = assigns) do
assigns = ensure_aria_required_for_input(assigns)
~H"""
<fieldset class="mb-2 fieldset">
<label>
@ -475,6 +477,8 @@ defmodule MvWeb.CoreComponents do
end
def input(%{type: "textarea"} = assigns) do
assigns = ensure_aria_required_for_input(assigns)
~H"""
<fieldset class="mb-2 fieldset">
<label>
@ -502,6 +506,8 @@ defmodule MvWeb.CoreComponents do
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
assigns = ensure_aria_required_for_input(assigns)
~H"""
<fieldset class="mb-2 fieldset">
<label>
@ -529,6 +535,18 @@ defmodule MvWeb.CoreComponents do
"""
end
# WCAG 2.1: set aria-required when required is true so screen readers announce required state
defp ensure_aria_required_for_input(assigns) do
rest = assigns.rest || %{}
rest =
if rest[:required],
do: Map.put(rest, :aria_required, "true"),
else: rest
assign(assigns, :rest, rest)
end
# Helper used by inputs to generate form errors
defp error(assigns) do
~H"""

View file

@ -54,7 +54,7 @@ defmodule MvWeb.Layouts do
data-sidebar-expanded="true"
phx-hook="SidebarState"
>
<input id="mobile-drawer" type="checkbox" class="drawer-toggle" />
<input id="mobile-drawer" type="checkbox" class="drawer-toggle" phx-update="ignore" />
<div class="drawer-content flex flex-col relative z-0">
<!-- Mobile Header (only visible on mobile) -->

View file

@ -15,24 +15,98 @@
</script>
<script>
(() => {
const setTheme = (theme) => {
if (theme === "system") {
localStorage.removeItem("phx:theme");
document.documentElement.removeAttribute("data-theme");
} else {
localStorage.setItem("phx:theme", theme);
document.documentElement.setAttribute("data-theme", theme);
}
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const systemTheme = () => (mq.matches ? "dark" : "light");
// Single source of truth:
// - localStorage["phx:theme"] = "light" | "dark" (explicit override)
// - missing key => "system"
const storedTheme = () => localStorage.getItem("phx:theme") || "system";
const effectiveTheme = (t) => (t === "system" ? systemTheme() : t);
const applyThemeNow = (t) => {
document.documentElement.setAttribute("data-theme", effectiveTheme(t));
};
if (!document.documentElement.hasAttribute("data-theme")) {
setTheme(localStorage.getItem("phx:theme") || "system");
}
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
const syncToggle = () => {
const eff = effectiveTheme(storedTheme());
document.querySelectorAll("[data-theme-toggle]").forEach((el) => {
el.checked = eff === "dark";
});
};
const setTheme = (t) => {
if (t === "system") localStorage.removeItem("phx:theme");
else localStorage.setItem("phx:theme", t);
applyThemeNow(t);
syncToggle(); // if toggle exists already
};
// 1) Apply theme ASAP to match system on first paint
applyThemeNow(storedTheme());
// 2) Sync toggle once DOM is ready (fixes initial "light" toggle)
document.addEventListener("DOMContentLoaded", syncToggle);
// 3) If toggle appears later (LiveView render), sync immediately
const obs = new MutationObserver(() => {
if (document.querySelector("[data-theme-toggle]")) syncToggle();
});
obs.observe(document.documentElement, { childList: true, subtree: true });
window.addEventListener("phx:set-theme", ({ detail: { theme } }) => setTheme(theme));
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
mq.addEventListener("change", () => {
if (localStorage.getItem("phx:theme") === null) {
applyThemeNow("system");
syncToggle();
}
});
})();
</script>
</head>
<body>
<div
id="flash-group-root"
aria-live="polite"
class="z-50 flex flex-col gap-2 toast toast-top toast-end"
>
<.flash id="flash-success-root" kind={:success} flash={@flash} />
<.flash id="flash-warning-root" kind={:warning} flash={@flash} />
<.flash id="flash-info-root" kind={:info} flash={@flash} />
<.flash id="flash-error-root" kind={:error} flash={@flash} />
<.flash
id="client-error-root"
kind={:error}
title={gettext("We can't find the internet")}
phx-disconnected={
show(".phx-client-error #client-error-root") |> JS.remove_attribute("hidden")
}
phx-connected={hide("#client-error-root") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
<.flash
id="server-error-root"
kind={:error}
title={gettext("Something went wrong!")}
phx-disconnected={
show(".phx-server-error #server-error-root") |> JS.remove_attribute("hidden")
}
phx-connected={hide("#server-error-root") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
</div>
{@inner_content}
</body>
</html>

View file

@ -80,11 +80,11 @@ defmodule MvWeb.Layouts.Sidebar do
/>
<% end %>
<%= if can_access_page?(@current_user, PagePaths.membership_fee_types()) do %>
<%= if can_access_page?(@current_user, PagePaths.groups()) do %>
<.menu_item
href={~p"/membership_fee_types"}
icon="hero-currency-euro"
label={gettext("Fee Types")}
href={~p"/groups"}
icon="hero-user-group"
label={gettext("Groups")}
/>
<% end %>
@ -102,24 +102,26 @@ defmodule MvWeb.Layouts.Sidebar do
label={gettext("Administration")}
testid="sidebar-administration"
>
<%= if can_access_page?(@current_user, PagePaths.users()) do %>
<.menu_subitem href={~p"/users"} label={gettext("Users")} />
<%= if can_access_page?(@current_user, PagePaths.settings()) do %>
<.menu_subitem href={~p"/settings"} label={gettext("Basic settings")} />
<% end %>
<%= if can_access_page?(@current_user, PagePaths.groups()) do %>
<.menu_subitem href={~p"/groups"} label={gettext("Groups")} />
<% end %>
<%= if can_access_page?(@current_user, PagePaths.admin_roles()) do %>
<.menu_subitem href={~p"/admin/roles"} label={gettext("Roles")} />
<%= if can_access_page?(@current_user, PagePaths.admin_datafields()) do %>
<.menu_subitem href={~p"/admin/datafields"} label={gettext("Datafields")} />
<% end %>
<%= if can_access_page?(@current_user, PagePaths.membership_fee_settings()) do %>
<.menu_subitem
href={~p"/membership_fee_settings"}
label={gettext("Fee Settings")}
label={gettext("Membership fee settings")}
/>
<% end %>
<%= if can_access_page?(@current_user, PagePaths.settings()) do %>
<%= if can_access_page?(@current_user, PagePaths.admin_import()) do %>
<.menu_subitem href={~p"/admin/import"} label={gettext("Import")} />
<.menu_subitem href={~p"/settings"} label={gettext("Settings")} />
<% end %>
<%= if can_access_page?(@current_user, PagePaths.users()) do %>
<.menu_subitem href={~p"/users"} label={gettext("Users")} />
<% end %>
<%= if can_access_page?(@current_user, PagePaths.admin_roles()) do %>
<.menu_subitem href={~p"/admin/roles"} label={gettext("Roles")} />
<% end %>
</.menu_group>
<% end %>
@ -248,12 +250,17 @@ defmodule MvWeb.Layouts.Sidebar do
aria-label={gettext("Toggle dark mode")}
>
<.icon name="hero-sun" class="size-5" aria-hidden="true" />
<input
type="checkbox"
value="dark"
class="toggle toggle-sm theme-controller focus:outline-none"
aria-label={gettext("Toggle dark mode")}
/>
<div id="theme-toggle" phx-update="ignore">
<input
id="theme-toggle-input"
type="checkbox"
class="toggle toggle-sm focus:outline-none"
data-theme-toggle
onchange="window.dispatchEvent(new CustomEvent('phx:set-theme',{detail:{theme:this.checked?'dark':'light'}}))"
aria-label={gettext("Toggle dark mode")}
/>
</div>
<.icon name="hero-moon" class="size-5" aria-hidden="true" />
</label>
"""

View file

@ -45,28 +45,86 @@ defmodule MvWeb.AuthController do
- Generic authentication failures
"""
def failure(conn, activity, reason) do
Logger.warning(
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
)
log_failure_safely(activity, reason)
case {activity, reason} do
{{:rauthy, _action}, reason} ->
handle_rauthy_failure(conn, reason)
{{:oidc, _action}, reason} ->
handle_oidc_failure(conn, reason)
{_, %AshAuthentication.Errors.AuthenticationFailed{caused_by: caused_by}} ->
handle_authentication_failed(conn, caused_by)
_ ->
redirect_with_error(conn, gettext("Incorrect email or password"))
conn
|> put_flash(:error, gettext("Incorrect email or password"))
|> redirect(to: ~p"/sign-in")
end
end
# Handle all Rauthy (OIDC) authentication failures
defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do
# Log authentication failures safely, avoiding sensitive data for {:oidc, _} activities
defp log_failure_safely({:oidc, _action} = activity, reason) do
# For Assent errors, use safe_assent_meta to avoid logging tokens/URLs with query params
case reason do
%Assent.ServerUnreachableError{} = err ->
meta = safe_assent_meta(err)
message = format_safe_log_message("Authentication failure", activity, meta)
Logger.warning(message)
%Assent.InvalidResponseError{} = err ->
meta = safe_assent_meta(err)
message = format_safe_log_message("Authentication failure", activity, meta)
Logger.warning(message)
_ ->
# For other OIDC errors, log only error type, not full details
error_type = get_error_type(reason)
Logger.warning(
"Authentication failure - Activity: #{inspect(activity)}, Error type: #{error_type}"
)
end
end
defp log_failure_safely(activity, reason) do
# For non-OIDC activities, safe to log full reason
Logger.warning(
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
)
end
# Extract safe error type identifier without sensitive data
defp get_error_type(%struct{}), do: "#{struct}"
defp get_error_type(atom) when is_atom(atom), do: inspect(atom)
defp get_error_type(_other), do: "[redacted]"
# Format safe log message with metadata included in the message string
defp format_safe_log_message(base_message, activity, meta) when is_list(meta) do
activity_str = "Activity: #{inspect(activity)}"
meta_str = format_meta_string(meta)
"#{base_message} - #{activity_str}#{meta_str}"
end
defp format_meta_string([]), do: ""
defp format_meta_string(meta) when is_list(meta) do
parts =
Enum.map(meta, fn
{:request_url, url} -> "Request URL: #{url}"
{:status, status} -> "Status: #{status}"
{:http_adapter, adapter} -> "HTTP Adapter: #{inspect(adapter)}"
_ -> nil
end)
|> Enum.filter(&(&1 != nil))
if Enum.empty?(parts), do: "", else: " - " <> Enum.join(parts, ", ")
end
# Handle all OIDC authentication failures
defp handle_oidc_failure(conn, %Ash.Error.Invalid{errors: errors}) do
handle_oidc_email_collision(conn, errors)
end
defp handle_rauthy_failure(conn, %AshAuthentication.Errors.AuthenticationFailed{
defp handle_oidc_failure(conn, %AshAuthentication.Errors.AuthenticationFailed{
caused_by: caused_by
}) do
case caused_by do
@ -74,14 +132,46 @@ defmodule MvWeb.AuthController do
handle_oidc_email_collision(conn, errors)
_ ->
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
conn
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|> redirect(to: ~p"/sign-in")
end
end
# Handle Assent server unreachable errors (network/connectivity issues)
defp handle_oidc_failure(conn, %Assent.ServerUnreachableError{} = _err) do
# Logging already done safely in failure/3 via log_failure_safely/2
# No need to log again here to avoid duplicate logs
conn
|> put_flash(
:error,
gettext("The authentication server is currently unavailable. Please try again later.")
)
|> redirect(to: ~p"/sign-in")
end
# Handle Assent invalid response errors (configuration or malformed responses)
defp handle_oidc_failure(conn, %Assent.InvalidResponseError{} = _err) do
# Logging already done safely in failure/3 via log_failure_safely/2
# No need to log again here to avoid duplicate logs
conn
|> put_flash(
:error,
gettext("Authentication configuration error. Please contact the administrator.")
)
|> redirect(to: ~p"/sign-in")
end
# Catch-all clause for any other error types
defp handle_rauthy_failure(conn, reason) do
Logger.warning("Unhandled Rauthy failure reason: #{inspect(reason)}")
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
defp handle_oidc_failure(conn, _reason) do
# Logging already done safely in failure/3 via log_failure_safely/2
# No need to log again here to avoid duplicate logs
conn
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|> redirect(to: ~p"/sign-in")
end
# Handle generic AuthenticationFailed errors
@ -93,14 +183,20 @@ defmodule MvWeb.AuthController do
You can confirm your account using the link we sent to you, or by resetting your password.
""")
redirect_with_error(conn, message)
conn
|> put_flash(:error, message)
|> redirect(to: ~p"/sign-in")
else
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
conn
|> put_flash(:error, gettext("Authentication failed. Please try again."))
|> redirect(to: ~p"/sign-in")
end
end
defp handle_authentication_failed(conn, _other) do
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
conn
|> put_flash(:error, gettext("Authentication failed. Please try again."))
|> redirect(to: ~p"/sign-in")
end
# Handle OIDC email collision - user needs to verify password to link accounts
@ -112,7 +208,10 @@ defmodule MvWeb.AuthController do
nil ->
# Check if it's a "different OIDC account" error or email uniqueness error
error_message = extract_meaningful_error_message(errors)
redirect_with_error(conn, error_message)
conn
|> put_flash(:error, error_message)
|> redirect(to: ~p"/sign-in")
end
end
@ -177,13 +276,47 @@ defmodule MvWeb.AuthController do
|> redirect(to: ~p"/auth/link-oidc-account")
end
# Generic error redirect helper
defp redirect_with_error(conn, message) do
conn
|> put_flash(:error, message)
|> redirect(to: ~p"/sign-in")
# Extract safe metadata from Assent errors for logging
# Never logs sensitive data: no tokens, secrets, or full request URLs
# Returns keyword list for Logger.warning/2
defp safe_assent_meta(%{request_url: url} = err) when is_binary(url) do
[
request_url: redact_url(url),
http_adapter: Map.get(err, :http_adapter)
]
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
end
# Handle InvalidResponseError which has :response field (HTTPResponse struct)
defp safe_assent_meta(%{response: %{status: status} = response} = err) do
[
status: status,
http_adapter: Map.get(response, :http_adapter) || Map.get(err, :http_adapter)
]
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
end
defp safe_assent_meta(err) do
# Only extract safe, simple fields
[
http_adapter: Map.get(err, :http_adapter)
]
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
end
# Redact URL to only show scheme and host, hiding path, query, and fragments
defp redact_url(url) when is_binary(url) do
case URI.parse(url) do
%URI{scheme: scheme, host: host} when not is_nil(scheme) and not is_nil(host) ->
"#{scheme}://#{host}"
_ ->
"[redacted]"
end
end
defp redact_url(_), do: "[redacted]"
def sign_out(conn, _params) do
return_to = get_session(conn, :return_to) || ~p"/"

View file

@ -18,7 +18,8 @@ defmodule MvWeb.MemberExportController do
alias MvWeb.MemberLive.Index.MembershipFeeStatus
use Gettext, backend: MvWeb.Gettext
@member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
["membership_fee_type", "groups"]
@computed_export_fields ["membership_fee_status"]
@custom_field_prefix Mv.Constants.custom_field_prefix()
@ -83,6 +84,7 @@ defmodule MvWeb.MemberExportController do
domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
selectable = Enum.filter(member_fields, fn f -> f in domain_fields end)
computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end)
# "groups" is neither a domain field nor a computed field, it's handled separately
{selectable, computed}
end
@ -235,12 +237,20 @@ defmodule MvWeb.MemberExportController do
need_cycles =
parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields
need_groups = "groups" in parsed.member_fields
need_membership_fee_type =
"membership_fee_type" in parsed.member_fields or
parsed.sort_field == "membership_fee_type"
query =
Member
|> Ash.Query.new()
|> Ash.Query.select(select_fields)
|> load_custom_field_values_query(parsed.custom_field_ids)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|> maybe_load_groups(need_groups)
|> maybe_load_membership_fee_type(need_membership_fee_type)
query =
if parsed.selected_ids != [] do
@ -284,6 +294,19 @@ defmodule MvWeb.MemberExportController do
MembershipFeeStatus.load_cycles_for_members(query, show_current)
end
defp maybe_load_groups(query, false), do: query
defp maybe_load_groups(query, true) do
# Load groups with id and name only (for export formatting)
Ash.Query.load(query, groups: [:id, :name])
end
defp maybe_load_membership_fee_type(query, false), do: query
defp maybe_load_membership_fee_type(query, true) do
Ash.Query.load(query, membership_fee_type: [:id, :name])
end
# Adds computed field values to members (e.g. membership_fee_status)
defp add_computed_fields(members, computed_fields, show_current_cycle) do
if "membership_fee_status" in computed_fields do
@ -329,22 +352,47 @@ defmodule MvWeb.MemberExportController do
defp maybe_sort_export(query, _field, nil), do: {query, false}
defp maybe_sort_export(query, field, order) when is_binary(field) do
if custom_field_sort?(field) do
# Custom field sort → in-memory nach dem Read (wie Tabelle)
{query, true}
else
field_atom = String.to_existing_atom(field)
cond do
field == "groups" ->
{query, true}
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
else
{query, false}
end
field == "membership_fee_type" ->
apply_membership_fee_type_sort_export(query, order)
custom_field_sort?(field) ->
{query, true}
true ->
apply_member_field_sort_export(query, field, order)
end
rescue
ArgumentError -> {query, false}
end
defp apply_membership_fee_type_sort_export(query, order) do
order_atom = if order == "desc", do: :desc, else: :asc
{Ash.Query.sort(query, [{"membership_fee_type.name", order_atom}]), false}
end
defp apply_member_field_sort_export(query, field, order) do
field_atom = String.to_existing_atom(field)
sortable =
field_atom in (Mv.Constants.member_fields() -- [:notes]) or
field_atom == :membership_fee_type
if sortable do
order_atom = if order == "desc", do: :desc, else: :asc
sort_field =
if field_atom == :membership_fee_type, do: :membership_fee_type_id, else: field_atom
{Ash.Query.sort(query, [{sort_field, order_atom}]), false}
else
{query, false}
end
end
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
# ------------------------------------------------------------------
@ -358,6 +406,15 @@ defmodule MvWeb.MemberExportController do
defp sort_members_by_custom_field_export(members, field, order, custom_fields)
when is_binary(field) do
order = order || "asc"
if field == "groups" do
sort_members_by_groups_export(members, order)
else
sort_by_custom_field_value(members, field, order, custom_fields)
end
end
defp sort_by_custom_field_value(members, field, order, custom_fields) do
id_str = String.trim_leading(field, @custom_field_prefix)
custom_field =
@ -387,6 +444,26 @@ defmodule MvWeb.MemberExportController do
end
end
defp sort_members_by_groups_export(members, order) do
# Members with groups first, then by first group name alphabetically (min = first by sort order)
# Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2
first_group_name = fn member ->
(member.groups || [])
|> Enum.map(& &1.name)
|> Enum.min(fn -> nil end)
end
members
|> Enum.sort_by(fn member ->
name = first_group_name.(member)
# Nil (no groups) sorts last in asc, first in desc
{name == nil, name || ""}
end)
|> then(fn list ->
if order == "desc", do: Enum.reverse(list), else: list
end)
end
defp has_non_empty_custom_field_value?(member, custom_field) do
case find_cfv(member, custom_field) do
nil ->
@ -441,6 +518,32 @@ defmodule MvWeb.MemberExportController do
}
end)
membership_fee_type_col =
if "membership_fee_type" in parsed.member_fields do
[
%{
header: membership_fee_type_field_header(conn),
kind: :membership_fee_type,
key: :membership_fee_type
}
]
else
[]
end
groups_col =
if "groups" in parsed.member_fields do
[
%{
header: groups_field_header(conn),
kind: :groups,
key: :groups
}
]
else
[]
end
custom_cols =
parsed.custom_field_ids
|> Enum.map(fn id ->
@ -459,7 +562,8 @@ defmodule MvWeb.MemberExportController do
end)
|> Enum.reject(&is_nil/1)
member_cols ++ computed_cols ++ custom_cols
# Table order: ... membership_fee_start_date, membership_fee_type, membership_fee_status, groups, custom
member_cols ++ membership_fee_type_col ++ computed_cols ++ groups_col ++ custom_cols
end
# --- headers: use MemberFields.label for translations ---
@ -499,6 +603,14 @@ defmodule MvWeb.MemberExportController do
cf.name
end
defp membership_fee_type_field_header(_conn) do
MemberFields.label(:membership_fee_type)
end
defp groups_field_header(_conn) do
MemberFields.label(:groups)
end
defp humanize_field(str) do
str
|> String.replace("_", " ")

View file

@ -20,7 +20,8 @@ defmodule MvWeb.MemberPdfExportController do
@invalid_json_message "invalid JSON"
@export_failed_message "Failed to generate PDF export"
@allowed_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
@allowed_member_field_strings (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
["membership_fee_type", "groups"]
def export(conn, %{"payload" => payload}) when is_binary(payload) do
actor = current_actor(conn)

View file

@ -84,7 +84,7 @@ defmodule MvWeb.LinkOidcAccountLive do
:info,
dgettext("auth", "Account activated! Redirecting to complete sign-in...")
)
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/rauthy")
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/oidc")
{:error, error} ->
Logger.warning(
@ -223,7 +223,7 @@ defmodule MvWeb.LinkOidcAccountLive do
"Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
)
)
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/rauthy")}
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/oidc")}
{:error, error} ->
Logger.warning(

View file

@ -0,0 +1,101 @@
defmodule MvWeb.SignInLive do
@moduledoc """
Custom sign-in page with language selector and conditional Single Sign-On button.
- 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.
"""
use Phoenix.LiveView
use Gettext, backend: MvWeb.Gettext
alias AshAuthentication.Phoenix.Components
alias Mv.Config
@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")
Gettext.put_locale(MvWeb.Gettext, locale)
socket =
socket
|> assign(overrides: overrides)
|> assign_new(:otp_app, fn -> nil end)
|> assign(:path, session["path"] || "/")
|> assign(:reset_path, session["reset_path"])
|> assign(:register_path, session["register_path"])
|> assign(:current_tenant, session["tenant"])
|> assign(:resources, session["resources"])
|> assign(:context, session["context"] || %{})
|> assign(:auth_routes_prefix, session["auth_routes_prefix"])
|> assign(:gettext_fn, session["gettext_fn"])
|> assign(:live_action, :sign_in)
|> 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)
{:ok, socket}
end
@impl true
def handle_params(_, _uri, socket) do
{:noreply, socket}
end
@impl true
def render(assigns) do
~H"""
<div
id="sign-in-page"
class={@root_class}
data-oidc-configured={to_string(@oidc_configured)}
data-oidc-only={to_string(@oidc_only)}
data-locale={@locale}
>
<%!-- 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}
/>
</div>
"""
end
end

View file

@ -0,0 +1,132 @@
defmodule MvWeb.DatafieldsLive do
@moduledoc """
LiveView for managing member field visibility/required and custom fields (datafields).
Renders MemberFieldLive.IndexComponent and CustomFieldLive.IndexComponent.
Moved from GlobalSettingsLive (Memberdata section) to a dedicated page.
"""
use MvWeb, :live_view
alias Mv.Membership
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@impl true
def mount(_params, _session, socket) do
{:ok, settings} = Membership.get_settings()
{:ok,
socket
|> assign(:page_title, gettext("Datafields"))
|> assign(:settings, settings)
|> assign(:active_editing_section, nil)}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user} club_name={@settings.club_name}>
<.header>
{gettext("Datafields")}
<:subtitle>
{gettext("Configure member fields and custom data fields.")}
</:subtitle>
</.header>
<.form_section title={gettext("Member fields")}>
<.live_component
:if={@active_editing_section != :custom_fields}
module={MvWeb.MemberFieldLive.IndexComponent}
id="member-fields-component"
settings={@settings}
/>
</.form_section>
<.form_section title={gettext("Custom fields")}>
<.live_component
:if={@active_editing_section != :member_fields}
module={MvWeb.CustomFieldLive.IndexComponent}
id="custom-fields-component"
actor={@current_user}
/>
</.form_section>
</Layouts.app>
"""
end
@impl true
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
send_update(MvWeb.CustomFieldLive.IndexComponent,
id: "custom-fields-component",
show_form: false
)
{:noreply,
socket
|> assign(:active_editing_section, nil)
|> put_flash(:info, gettext("Data field %{action} successfully", action: action))}
end
@impl true
def handle_info({:custom_field_deleted, _custom_field}, socket) do
{:noreply, put_flash(socket, :info, gettext("Data field deleted successfully"))}
end
@impl true
def handle_info({:custom_field_delete_error, error}, socket) do
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to delete data field: %{error}", error: inspect(error))
)}
end
@impl true
def handle_info(:custom_field_slug_mismatch, socket) do
{:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))}
end
def handle_info({:custom_fields_load_error, _error}, socket) do
{:noreply,
put_flash(
socket,
:error,
gettext("Could not load data fields. Please check your permissions.")
)}
end
@impl true
def handle_info({:editing_section_changed, section}, socket) do
{:noreply, assign(socket, :active_editing_section, section)}
end
@impl true
def handle_info({:member_field_saved, _member_field, action}, socket) do
{:ok, updated_settings} = Membership.get_settings()
send_update(MvWeb.MemberFieldLive.IndexComponent,
id: "member-fields-component",
show_form: false,
settings: updated_settings
)
{:noreply,
socket
|> assign(:settings, updated_settings)
|> assign(:active_editing_section, nil)
|> put_flash(:info, gettext("Member field %{action} successfully", action: action))}
end
@impl true
def handle_info({:member_field_visibility_updated}, socket) do
{:ok, updated_settings} = Membership.get_settings()
send_update(MvWeb.MemberFieldLive.IndexComponent,
id: "member-fields-component",
settings: updated_settings
)
{:noreply, assign(socket, :settings, updated_settings)}
end
end

View file

@ -23,6 +23,9 @@ defmodule MvWeb.GlobalSettingsLive do
"""
use MvWeb, :live_view
require Ash.Query
import Ash.Expr
alias Mv.Membership
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@ -31,21 +34,43 @@ defmodule MvWeb.GlobalSettingsLive do
def mount(_params, session, socket) do
{:ok, settings} = Membership.get_settings()
# Get locale from session for translations
locale = session["locale"] || "de"
# Get locale from session; same fallback as router/LiveUserAuth (respects config :default_locale in test)
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
Gettext.put_locale(MvWeb.Gettext, locale)
socket =
socket
|> assign(:page_title, gettext("Settings"))
|> assign(:settings, settings)
|> assign(:active_editing_section, nil)
|> assign(:locale, locale)
|> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?())
|> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?())
|> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?())
|> assign(:vereinfacht_club_id_env_set, Mv.Config.vereinfacht_club_id_env_set?())
|> assign(:vereinfacht_app_url_env_set, Mv.Config.vereinfacht_app_url_env_set?())
|> assign(:vereinfacht_api_key_set, present?(settings.vereinfacht_api_key))
|> assign(:last_vereinfacht_sync_result, nil)
|> assign(:vereinfacht_test_result, nil)
|> assign(:oidc_env_configured, Mv.Config.oidc_env_configured?())
|> assign(:oidc_client_id_env_set, Mv.Config.oidc_client_id_env_set?())
|> assign(:oidc_base_url_env_set, Mv.Config.oidc_base_url_env_set?())
|> assign(:oidc_redirect_uri_env_set, Mv.Config.oidc_redirect_uri_env_set?())
|> assign(:oidc_client_secret_env_set, Mv.Config.oidc_client_secret_env_set?())
|> assign(:oidc_admin_group_name_env_set, Mv.Config.oidc_admin_group_name_env_set?())
|> assign(:oidc_groups_claim_env_set, Mv.Config.oidc_groups_claim_env_set?())
|> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?())
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> assign(:oidc_client_secret_set, present?(settings.oidc_client_secret))
|> assign_form()
{:ok, socket}
end
defp present?(nil), do: false
defp present?(""), do: false
defp present?(s) when is_binary(s), do: String.trim(s) != ""
defp present?(_), do: false
@impl true
def render(assigns) do
~H"""
@ -74,21 +99,240 @@ defmodule MvWeb.GlobalSettingsLive do
</.button>
</.form>
</.form_section>
<%!-- Memberdata Section --%>
<.form_section title={gettext("Memberdata")}>
<.live_component
:if={@active_editing_section != :custom_fields}
module={MvWeb.MemberFieldLive.IndexComponent}
id="member-fields-component"
settings={@settings}
/>
<%!-- Custom Fields Section --%>
<.live_component
:if={@active_editing_section != :member_fields}
module={MvWeb.CustomFieldLive.IndexComponent}
id="custom-fields-component"
actor={@current_user}
/>
<%!-- Vereinfacht Integration Section --%>
<.form_section title={gettext("Vereinfacht Integration")}>
<%= if @vereinfacht_env_configured do %>
<p class="text-sm text-base-content/70 mb-4">
{gettext("Some values are set via environment variables. Those fields are read-only.")}
</p>
<% end %>
<.form for={@form} id="vereinfacht-form" phx-change="validate" phx-submit="save">
<div class="grid gap-4">
<.input
field={@form[:vereinfacht_api_url]}
type="text"
label={gettext("API URL")}
disabled={@vereinfacht_api_url_env_set}
placeholder={
if(@vereinfacht_api_url_env_set,
do: gettext("From VEREINFACHT_API_URL"),
else: "https://api.verein.visuel.dev/api/v1"
)
}
/>
<div class="form-control">
<label class="label" for={@form[:vereinfacht_api_key].id}>
<span class="label-text">{gettext("API Key")}</span>
<%= if @vereinfacht_api_key_set do %>
<span class="label-text-alt badge badge-ghost">{gettext("(set)")}</span>
<% end %>
</label>
<.input
field={@form[:vereinfacht_api_key]}
type="password"
label=""
disabled={@vereinfacht_api_key_env_set}
placeholder={
if(@vereinfacht_api_key_env_set,
do: gettext("From VEREINFACHT_API_KEY"),
else:
if(@vereinfacht_api_key_set,
do: gettext("Leave blank to keep current"),
else: nil
)
)
}
/>
</div>
<.input
field={@form[:vereinfacht_club_id]}
type="text"
label={gettext("Club ID")}
disabled={@vereinfacht_club_id_env_set}
placeholder={
if(@vereinfacht_club_id_env_set, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2")
}
/>
<.input
field={@form[:vereinfacht_app_url]}
type="text"
label={gettext("App URL (contact view link)")}
disabled={@vereinfacht_app_url_env_set}
placeholder={
if(@vereinfacht_app_url_env_set,
do: gettext("From VEREINFACHT_APP_URL"),
else: "https://app.verein.visuel.dev"
)
}
/>
</div>
<.button
:if={
not (@vereinfacht_api_url_env_set and @vereinfacht_api_key_env_set and
@vereinfacht_club_id_env_set)
}
phx-disable-with={gettext("Saving...")}
variant="primary"
class="mt-2"
>
{gettext("Save Vereinfacht Settings")}
</.button>
<div class="mt-2 flex flex-wrap gap-2">
<.button
:if={Mv.Config.vereinfacht_configured?()}
type="button"
phx-click="test_vereinfacht_connection"
phx-disable-with={gettext("Testing...")}
class="btn-outline"
>
{gettext("Test Integration")}
</.button>
<.button
:if={Mv.Config.vereinfacht_configured?()}
type="button"
phx-click="sync_vereinfacht_contacts"
phx-disable-with={gettext("Syncing...")}
class="btn-outline"
>
{gettext("Sync all members without Vereinfacht contact")}
</.button>
</div>
<%= if @vereinfacht_test_result do %>
<.vereinfacht_test_result result={@vereinfacht_test_result} />
<% end %>
<%= if @last_vereinfacht_sync_result do %>
<.vereinfacht_sync_result result={@last_vereinfacht_sync_result} />
<% end %>
</.form>
</.form_section>
<%!-- OIDC Section --%>
<.form_section title={gettext("OIDC")}>
<%= if @oidc_env_configured do %>
<p class="text-sm text-base-content/70 mb-4">
{gettext("Some values are set via environment variables. Those fields are read-only.")}
</p>
<% end %>
<.form for={@form} id="oidc-form" phx-change="validate" phx-submit="save">
<div class="grid gap-4">
<.input
field={@form[:oidc_client_id]}
type="text"
label={gettext("Client ID")}
disabled={@oidc_client_id_env_set}
placeholder={
if(@oidc_client_id_env_set, do: gettext("From OIDC_CLIENT_ID"), else: "mv")
}
/>
<.input
field={@form[:oidc_base_url]}
type="text"
label={gettext("Base URL")}
disabled={@oidc_base_url_env_set}
placeholder={
if(@oidc_base_url_env_set,
do: gettext("From OIDC_BASE_URL"),
else: "http://localhost:8080/auth/v1"
)
}
/>
<.input
field={@form[:oidc_redirect_uri]}
type="text"
label={gettext("Redirect URI")}
disabled={@oidc_redirect_uri_env_set}
placeholder={
if(@oidc_redirect_uri_env_set,
do: gettext("From OIDC_REDIRECT_URI"),
else: "http://localhost:4000/auth/user/oidc/callback"
)
}
/>
<div class="form-control">
<label class="label" for={@form[:oidc_client_secret].id}>
<span class="label-text">{gettext("Client Secret")}</span>
<%= if @oidc_client_secret_set do %>
<span class="label-text-alt badge badge-ghost">{gettext("(set)")}</span>
<% end %>
</label>
<.input
field={@form[:oidc_client_secret]}
type="password"
label=""
disabled={@oidc_client_secret_env_set}
placeholder={
if(@oidc_client_secret_env_set,
do: gettext("From OIDC_CLIENT_SECRET"),
else:
if(@oidc_client_secret_set,
do: gettext("Leave blank to keep current"),
else: nil
)
)
}
/>
</div>
<.input
field={@form[:oidc_admin_group_name]}
type="text"
label={gettext("Admin group name")}
disabled={@oidc_admin_group_name_env_set}
placeholder={
if(@oidc_admin_group_name_env_set,
do: gettext("From OIDC_ADMIN_GROUP_NAME"),
else: gettext("e.g. admin")
)
}
/>
<.input
field={@form[:oidc_groups_claim]}
type="text"
label={gettext("Groups claim")}
disabled={@oidc_groups_claim_env_set}
placeholder={
if(@oidc_groups_claim_env_set,
do: gettext("From OIDC_GROUPS_CLAIM"),
else: "groups"
)
}
/>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-2">
<.input
field={@form[:oidc_only]}
type="checkbox"
class="checkbox checkbox-sm"
disabled={@oidc_only_env_set or not @oidc_configured}
/>
<span class="label-text">
{gettext("Only OIDC sign-in (hide password login)")}
<%= if @oidc_only_env_set do %>
<span class="label-text-alt text-base-content/70 ml-1">
({gettext("From OIDC_ONLY")})
</span>
<% end %>
</span>
</label>
<p class="label-text-alt text-base-content/70 mt-1">
{gettext(
"When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
)}
</p>
</div>
</div>
<.button
:if={
not (@oidc_client_id_env_set and @oidc_base_url_env_set and
@oidc_redirect_uri_env_set and @oidc_client_secret_env_set and
@oidc_admin_group_name_env_set and @oidc_groups_claim_env_set and
@oidc_only_env_set)
}
phx-disable-with={gettext("Saving...")}
variant="primary"
class="mt-2"
>
{gettext("Save OIDC Settings")}
</.button>
</.form>
</.form_section>
</Layouts.app>
"""
@ -100,18 +344,71 @@ defmodule MvWeb.GlobalSettingsLive do
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
end
@impl true
def handle_event("test_vereinfacht_connection", _params, socket) do
result = Mv.Vereinfacht.test_connection()
{:noreply, assign(socket, :vereinfacht_test_result, result)}
end
@impl true
def handle_event("sync_vereinfacht_contacts", _params, socket) do
case Mv.Vereinfacht.sync_members_without_contact() do
{:ok, %{synced: synced, errors: errors}} ->
errors_with_names = enrich_sync_errors(errors)
result = %{synced: synced, errors: errors_with_names}
socket =
socket
|> assign(:last_vereinfacht_sync_result, result)
|> put_flash(
:info,
if(errors_with_names == [],
do: gettext("Synced %{count} member(s) to Vereinfacht.", count: synced),
else:
gettext("Synced %{count} member(s). %{error_count} failed.",
count: synced,
error_count: length(errors_with_names)
)
)
)
{:noreply, socket}
{:error, :not_configured} ->
{:noreply,
put_flash(
socket,
:error,
gettext("Vereinfacht is not configured. Set API URL, API Key, and Club ID.")
)}
end
end
@impl true
def handle_event("save", %{"setting" => setting_params}, socket) do
actor = MvWeb.LiveHelpers.current_actor(socket)
# Never send blank API key / client secret so we do not overwrite stored secrets
setting_params_clean =
setting_params
|> drop_blank_vereinfacht_api_key()
|> drop_blank_oidc_client_secret()
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params, actor) do
saves_vereinfacht = vereinfacht_params?(setting_params_clean)
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do
{:ok, _updated_settings} ->
# Reload settings from database to ensure all dependent data is updated
{:ok, fresh_settings} = Membership.get_settings()
test_result =
if saves_vereinfacht, do: Mv.Vereinfacht.test_connection(), else: nil
socket =
socket
|> assign(:settings, fresh_settings)
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|> assign(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret))
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> assign(:vereinfacht_test_result, test_result)
|> put_flash(:info, gettext("Settings updated successfully"))
|> assign_form()
@ -122,89 +419,48 @@ defmodule MvWeb.GlobalSettingsLive do
end
end
@impl true
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
send_update(MvWeb.CustomFieldLive.IndexComponent,
id: "custom-fields-component",
show_form: false
)
@vereinfacht_param_keys ~w[vereinfacht_api_url vereinfacht_api_key vereinfacht_club_id vereinfacht_app_url]
{:noreply,
socket
|> assign(:active_editing_section, nil)
|> put_flash(:info, gettext("Data field %{action} successfully", action: action))}
defp vereinfacht_params?(params) when is_map(params) do
Enum.any?(@vereinfacht_param_keys, &Map.has_key?(params, &1))
end
@impl true
def handle_info({:custom_field_deleted, _custom_field}, socket) do
{:noreply, put_flash(socket, :info, gettext("Data field deleted successfully"))}
defp drop_blank_vereinfacht_api_key(params) when is_map(params) do
case params do
%{"vereinfacht_api_key" => v} when v in [nil, ""] ->
Map.delete(params, "vereinfacht_api_key")
_ ->
params
end
end
@impl true
def handle_info({:custom_field_delete_error, error}, socket) do
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to delete data field: %{error}", error: inspect(error))
)}
end
defp drop_blank_oidc_client_secret(params) when is_map(params) do
case params do
%{"oidc_client_secret" => v} when v in [nil, ""] ->
Map.delete(params, "oidc_client_secret")
@impl true
def handle_info(:custom_field_slug_mismatch, socket) do
{:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))}
end
def handle_info({:custom_fields_load_error, _error}, socket) do
{:noreply,
put_flash(
socket,
:error,
gettext("Could not load data fields. Please check your permissions.")
)}
end
@impl true
def handle_info({:editing_section_changed, section}, socket) do
{:noreply, assign(socket, :active_editing_section, section)}
end
@impl true
def handle_info({:member_field_saved, _member_field, action}, socket) do
# Reload settings to get updated member_field_visibility
{:ok, updated_settings} = Membership.get_settings()
# Send update to member fields component to close form
send_update(MvWeb.MemberFieldLive.IndexComponent,
id: "member-fields-component",
show_form: false,
settings: updated_settings
)
{:noreply,
socket
|> assign(:settings, updated_settings)
|> assign(:active_editing_section, nil)
|> put_flash(:info, gettext("Member field %{action} successfully", action: action))}
end
@impl true
def handle_info({:member_field_visibility_updated}, socket) do
# Legacy event - reload settings and update component
{:ok, updated_settings} = Membership.get_settings()
send_update(MvWeb.MemberFieldLive.IndexComponent,
id: "member-fields-component",
settings: updated_settings
)
{:noreply, assign(socket, :settings, updated_settings)}
_ ->
params
end
end
defp assign_form(%{assigns: %{settings: settings}} = socket) do
# Show ENV values in disabled fields (Vereinfacht and OIDC); never expose API key / client secret
settings_display =
settings
|> merge_vereinfacht_env_values()
|> merge_oidc_env_values()
settings_for_form = %{
settings_display
| vereinfacht_api_key: nil,
oidc_client_secret: nil
}
form =
AshPhoenix.Form.for_update(
settings,
settings_for_form,
:update,
api: Membership,
as: "setting",
@ -213,4 +469,237 @@ defmodule MvWeb.GlobalSettingsLive do
assign(socket, form: to_form(form))
end
defp put_if_env_set(map, _key, false, _value), do: map
defp put_if_env_set(map, key, true, value), do: Map.put(map, key, value)
defp merge_vereinfacht_env_values(s) do
s
|> put_if_env_set(
:vereinfacht_api_url,
Mv.Config.vereinfacht_api_url_env_set?(),
Mv.Config.vereinfacht_api_url()
)
|> put_if_env_set(
:vereinfacht_club_id,
Mv.Config.vereinfacht_club_id_env_set?(),
Mv.Config.vereinfacht_club_id()
)
|> put_if_env_set(
:vereinfacht_app_url,
Mv.Config.vereinfacht_app_url_env_set?(),
Mv.Config.vereinfacht_app_url()
)
end
defp merge_oidc_env_values(s) do
s
|> put_if_env_set(
:oidc_client_id,
Mv.Config.oidc_client_id_env_set?(),
Mv.Config.oidc_client_id()
)
|> put_if_env_set(
:oidc_base_url,
Mv.Config.oidc_base_url_env_set?(),
Mv.Config.oidc_base_url()
)
|> put_if_env_set(
:oidc_redirect_uri,
Mv.Config.oidc_redirect_uri_env_set?(),
Mv.Config.oidc_redirect_uri()
)
|> put_if_env_set(
:oidc_admin_group_name,
Mv.Config.oidc_admin_group_name_env_set?(),
Mv.Config.oidc_admin_group_name()
)
|> put_if_env_set(
:oidc_groups_claim,
Mv.Config.oidc_groups_claim_env_set?(),
Mv.Config.oidc_groups_claim()
)
|> put_if_oidc_only_env_set()
end
defp put_if_oidc_only_env_set(s) do
if Mv.Config.oidc_only_env_set?() do
Map.put(s, :oidc_only, Mv.Config.oidc_only?())
else
s
end
end
defp enrich_sync_errors([]), do: []
defp enrich_sync_errors(errors) when is_list(errors) do
name_by_id = fetch_member_names_by_ids(Enum.map(errors, fn {id, _} -> id end))
Enum.map(errors, fn {member_id, reason} ->
%{
member_id: member_id,
member_name: Map.get(name_by_id, member_id) || to_string(member_id),
message: Mv.Vereinfacht.format_error(reason),
detail: extract_vereinfacht_detail(reason)
}
end)
end
defp fetch_member_names_by_ids(ids) do
actor = Mv.Helpers.SystemActor.get_system_actor()
opts = Mv.Helpers.ash_actor_opts(actor)
query = Ash.Query.filter(Mv.Membership.Member, expr(id in ^ids))
case Ash.read(query, opts) do
{:ok, members} ->
Map.new(members, fn m -> {m.id, MvWeb.Helpers.MemberHelpers.display_name(m)} end)
_ ->
%{}
end
end
defp extract_vereinfacht_detail({:http, _status, detail}) when is_binary(detail), do: detail
defp extract_vereinfacht_detail(_), do: nil
defp translate_vereinfacht_message(%{detail: detail}) when is_binary(detail) do
gettext("Vereinfacht: %{detail}",
detail: Gettext.dgettext(MvWeb.Gettext, "default", detail)
)
end
defp translate_vereinfacht_message(%{message: message}) do
Gettext.dgettext(MvWeb.Gettext, "default", message)
end
attr :result, :any, required: true
defp vereinfacht_test_result(%{result: {:ok, :connected}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-success bg-success/10 text-success-aa text-sm">
<.icon name="hero-check-circle" class="size-5 shrink-0" />
<span>{gettext("Connection successful. API URL, API Key and Club ID are valid.")}</span>
</div>
"""
end
defp vereinfacht_test_result(%{result: {:error, :not_configured}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-warning bg-warning/10 text-warning-aa text-sm">
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
<span>{gettext("Not configured. Please set API URL, API Key and Club ID.")}</span>
</div>
"""
end
defp vereinfacht_test_result(%{result: {:error, {:http, _status, :html_response}}} = assigns) do
~H"""
<div class="mt-3 flex items-start gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" />
<span>
{gettext(
"Connection failed. The URL does not point to a Vereinfacht API (received HTML instead of JSON)."
)}
</span>
</div>
"""
end
defp vereinfacht_test_result(%{result: {:error, {:http, 401, _}}} = assigns) do
~H"""
<div class="mt-3 flex items-start gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" />
<span>{gettext("Connection failed (HTTP 401): API key is invalid or missing.")}</span>
</div>
"""
end
defp vereinfacht_test_result(%{result: {:error, {:http, 403, _}}} = assigns) do
~H"""
<div class="mt-3 flex items-start gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" />
<span>
{gettext(
"Connection failed (HTTP 403): Access denied. Please check the Club ID and API key permissions."
)}
</span>
</div>
"""
end
defp vereinfacht_test_result(%{result: {:error, {:http, 404, _}}} = assigns) do
~H"""
<div class="mt-3 flex items-start gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" />
<span>
{gettext(
"Connection failed (HTTP 404): API endpoint not found. Please check the API URL (e.g. correct version path)."
)}
</span>
</div>
"""
end
defp vereinfacht_test_result(%{result: {:error, {:http, status, message}}} = assigns) do
assigns = assign(assigns, :status, status)
assigns = assign(assigns, :message, message)
~H"""
<div class="mt-3 flex items-start gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" />
<span>
{gettext("Connection failed (HTTP %{status}):", status: @status)}
<span class="ml-1">{@message}</span>
</span>
</div>
"""
end
defp vereinfacht_test_result(%{result: {:error, {:request_failed, _reason}}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0" />
<span>
{gettext("Connection failed. Could not reach the API (network error or wrong URL).")}
</span>
</div>
"""
end
defp vereinfacht_test_result(%{result: {:error, _}} = assigns) do
~H"""
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
<.icon name="hero-x-circle" class="size-5 shrink-0" />
<span>{gettext("Connection failed. Unknown error.")}</span>
</div>
"""
end
attr :result, :map, required: true
defp vereinfacht_sync_result(assigns) do
~H"""
<div class="mt-4 p-4 rounded-lg border border-base-300 bg-base-200 space-y-2">
<p class="font-medium">
{gettext("Last sync result:")}
<span class="text-success-aa ml-1">{gettext("%{count} synced", count: @result.synced)}</span>
<%= if @result.errors != [] do %>
<span class="text-error-aa ml-1">
{gettext("%{count} failed", count: length(@result.errors))}
</span>
<% end %>
</p>
<%= if @result.errors != [] do %>
<p class="text-sm text-base-content/70 mt-2">{gettext("Failed members:")}</p>
<ul class="list-disc list-inside text-sm space-y-1 max-h-48 overflow-y-auto">
<%= for err <- @result.errors do %>
<li>
<span class="font-medium">{err.member_name}</span>: {translate_vereinfacht_message(err)}
</li>
<% end %>
</ul>
<% end %>
</div>
"""
end
end

View file

@ -17,10 +17,12 @@ defmodule MvWeb.GroupLive.Show do
require Logger
import Ash.Expr
import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.Authorization
alias Mv.Membership
alias MvWeb.Helpers.MemberHelpers, as: MemberHelpers
@impl true
def mount(_params, _session, socket) do
@ -29,6 +31,7 @@ defmodule MvWeb.GroupLive.Show do
|> assign(:show_add_member_input, false)
|> assign(:member_search_query, "")
|> assign(:available_members, [])
|> assign(:add_member_candidates, [])
|> assign(:selected_member_ids, [])
|> assign(:selected_members, [])
|> assign(:show_member_dropdown, false)
@ -94,13 +97,21 @@ defmodule MvWeb.GroupLive.Show do
</h1>
<div class="flex gap-2">
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"}>
<%= if can?(@current_user, :update, @group) do %>
<.button
variant="primary"
navigate={~p"/groups/#{@group.slug}/edit"}
data-testid="group-show-edit-btn"
>
{gettext("Edit")}
</.button>
<% end %>
<%= if can?(@current_user, :destroy, Mv.Membership.Group) do %>
<.button class="btn-error" phx-click="open_delete_modal">
<%= if can?(@current_user, :destroy, @group) do %>
<.button
class="btn-error"
phx-click="open_delete_modal"
data-testid="group-show-delete-btn"
>
{gettext("Delete")}
</.button>
<% end %>
@ -123,7 +134,7 @@ defmodule MvWeb.GroupLive.Show do
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="mb-4">
<p class="mb-4" data-testid="group-show-member-count">
{ngettext(
"Total: %{count} member",
"Total: %{count} members",
@ -132,7 +143,7 @@ defmodule MvWeb.GroupLive.Show do
)}
</p>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<%= if can?(@current_user, :update, @group) do %>
<div class="mb-4">
<%= if assigns[:show_add_member_input] do %>
<div class="join w-full">
@ -160,6 +171,7 @@ defmodule MvWeb.GroupLive.Show do
<input
type="text"
id="member-search-input"
data-testid="group-show-member-search-input"
role="combobox"
phx-hook="ComboBox"
phx-focus="show_member_dropdown"
@ -228,6 +240,7 @@ defmodule MvWeb.GroupLive.Show do
type="button"
class="btn btn-primary join-item"
phx-click="add_selected_members"
data-testid="group-show-add-selected-members-btn"
disabled={Enum.empty?(@selected_member_ids)}
aria-label={gettext("Add members")}
>
@ -255,15 +268,17 @@ defmodule MvWeb.GroupLive.Show do
<% end %>
<%= if Enum.empty?(@group.members || []) do %>
<p class="text-base-content/50 italic">{gettext("No members in this group")}</p>
<p class="text-base-content/50 italic" data-testid="group-show-no-members">
{gettext("No members in this group")}
</p>
<% else %>
<div class="overflow-x-auto">
<div class="overflow-x-auto" data-testid="group-show-members-table">
<table class="table table-zebra">
<thead>
<tr>
<th>{gettext("Name")}</th>
<th>{gettext("Email")}</th>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<%= if can?(@current_user, :update, @group) do %>
<th class="w-0">{gettext("Actions")}</th>
<% end %>
</tr>
@ -291,13 +306,14 @@ defmodule MvWeb.GroupLive.Show do
<span class="text-base-content/50 italic"></span>
<% end %>
</td>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<%= if can?(@current_user, :update, @group) do %>
<td>
<button
type="button"
class="btn btn-ghost btn-sm text-error"
phx-click="remove_member"
phx-value-member_id={member.id}
data-testid="group-show-remove-member"
aria-label={gettext("Remove member from group")}
data-tooltip={gettext("Remove")}
>
@ -431,28 +447,31 @@ defmodule MvWeb.GroupLive.Show do
# Add Member Events
@impl true
def handle_event("show_add_member_input", _params, socket) do
# Reload group to ensure we have the latest members list
actor = current_actor(socket)
group = socket.assigns.group
socket = reload_group(socket, group.slug, actor)
# Load candidate members once (single DB read). Search/focus then filter in memory (R2).
socket =
socket
|> assign(:show_add_member_input, true)
|> assign(:member_search_query, "")
|> assign(:selected_member_ids, [])
|> assign(:selected_members, [])
|> assign(:show_member_dropdown, false)
|> assign(:focused_member_index, nil)
|> load_add_member_candidates()
{:noreply,
socket
|> assign(:show_add_member_input, true)
|> assign(:member_search_query, "")
|> assign(:available_members, [])
|> assign(:selected_member_ids, [])
|> assign(:selected_members, [])
|> assign(:show_member_dropdown, false)
|> assign(:focused_member_index, nil)}
{:noreply, socket}
end
@impl true
def handle_event("show_member_dropdown", _params, socket) do
# Use existing group.members for filtering; reload only on add/remove
# Filter in memory from preloaded candidates; no DB read (R2).
query = socket.assigns.member_search_query || ""
socket =
socket
|> load_available_members("")
|> assign(
:available_members,
filter_candidates_in_memory(socket.assigns.add_member_candidates, query)
)
|> assign(:show_member_dropdown, true)
|> assign(:focused_member_index, nil)
@ -466,6 +485,7 @@ defmodule MvWeb.GroupLive.Show do
|> assign(:show_add_member_input, false)
|> assign(:member_search_query, "")
|> assign(:available_members, [])
|> assign(:add_member_candidates, [])
|> assign(:selected_member_ids, [])
|> assign(:selected_members, [])
|> assign(:show_member_dropdown, false)
@ -532,11 +552,13 @@ defmodule MvWeb.GroupLive.Show do
@impl true
def handle_event("search_members", %{"member_search" => query}, socket) do
# Use existing group.members for filtering; reload only on add/remove
# Filter in memory from preloaded candidates; no DB read (R2).
candidates = socket.assigns.add_member_candidates || []
socket =
socket
|> assign(:member_search_query, query)
|> load_available_members(query)
|> assign(:available_members, filter_candidates_in_memory(candidates, query))
|> assign(:show_member_dropdown, true)
|> assign(:focused_member_index, nil)
@ -660,47 +682,69 @@ defmodule MvWeb.GroupLive.Show do
end
end
defp load_available_members(socket, query) do
# Load candidate members once when opening add-member UI (single DB read).
defp load_add_member_candidates(socket) do
require Ash.Query
current_member_ids = group_member_ids_set(socket.assigns.group)
base_query = available_members_base_query(query)
# Fetch more than 10, then exclude already-in-group and take 10 (avoids empty dropdown when first N are all in group)
fetch_limit = 50
limited_query = Ash.Query.limit(base_query, fetch_limit)
group = socket.assigns.group
exclude_ids = group_member_ids_set(group) |> MapSet.to_list()
actor = current_actor(socket)
case Ash.read(limited_query, actor: actor, domain: Mv.Membership) do
{:ok, members} ->
available =
members
|> Enum.reject(fn m -> MapSet.member?(current_member_ids, m.id) end)
|> Enum.take(10)
if exclude_ids == [] do
# No members in group; load first N members
query =
Mv.Membership.Member
|> Ash.Query.sort([:last_name, :first_name])
|> Ash.Query.limit(300)
assign(socket, available_members: available)
do_load_add_member_candidates(socket, query, actor)
else
query =
Mv.Membership.Member
|> Ash.Query.filter(expr(id not in ^exclude_ids))
|> Ash.Query.sort([:last_name, :first_name])
|> Ash.Query.limit(300)
do_load_add_member_candidates(socket, query, actor)
end
end
defp do_load_add_member_candidates(socket, query, actor) do
case Ash.read(query, actor: actor, domain: Mv.Membership) do
{:ok, candidates} ->
socket
|> assign(:add_member_candidates, candidates)
|> assign(:available_members, Enum.take(candidates, 10))
{:error, error} ->
Logger.warning("Failed to load available members for group: #{inspect(error)}")
Logger.warning("Failed to load add-member candidates: #{inspect(error)}")
socket
|> put_flash(:error, gettext("Could not load member search. Please try again."))
|> put_flash(:error, gettext("Could not load member list. Please try again."))
|> assign(:add_member_candidates, [])
|> assign(:available_members, [])
end
end
defp available_members_base_query(query) do
search_query = if query && String.trim(query) != "", do: String.trim(query), else: nil
# Filter preloaded candidates by query string (name/email). No DB read. R2.
defp filter_candidates_in_memory(candidates, query) when is_list(candidates) do
q = if is_binary(query), do: String.trim(query) |> String.downcase(), else: ""
if search_query do
Mv.Membership.Member
|> Ash.Query.for_read(:search, %{query: search_query})
if q == "" do
candidates |> Enum.take(10)
else
Mv.Membership.Member
|> Ash.Query.new()
candidates
|> Enum.filter(fn m ->
name = MemberHelpers.display_name(m) |> String.downcase()
email = (m.email || "") |> String.downcase()
String.contains?(name, q) or String.contains?(email, q)
end)
|> Enum.take(10)
end
end
defp filter_candidates_in_memory(_, _), do: []
defp group_member_ids_set(group) do
members = group.members || []
members |> Enum.map(& &1.id) |> MapSet.new()
@ -740,6 +784,7 @@ defmodule MvWeb.GroupLive.Show do
|> assign(:show_add_member_input, false)
|> assign(:member_search_query, "")
|> assign(:available_members, [])
|> assign(:add_member_candidates, [])
|> assign(:selected_member_ids, [])
|> assign(:selected_members, [])
|> assign(:show_member_dropdown, false)

View file

@ -92,14 +92,22 @@ defmodule MvWeb.ImportLive do
<Layouts.app flash={@flash} current_user={@current_user} club_name={@club_name}>
<%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %>
<%!-- CSV Import Section --%>
<.form_section title={gettext("Import Members (CSV)")}>
<Components.custom_fields_notice {assigns} />
<Components.template_links {assigns} />
<Components.import_form {assigns} />
<%= if @import_status == :running or @import_status == :done or @import_status == :error do %>
<Components.import_progress {assigns} />
<% end %>
</.form_section>
<div data-testid="import-page">
<.header>
{gettext("Import Members")}
<:subtitle>
{gettext("Import members from CSV files.")}
</:subtitle>
</.header>
<.form_section title={gettext("Choose CSV file")}>
<Components.custom_fields_notice {assigns} />
<Components.template_links {assigns} />
<Components.import_form {assigns} />
<%= if @import_status == :running or @import_status == :done or @import_status == :error do %>
<Components.import_progress {assigns} />
<% end %>
</.form_section>
</div>
<% else %>
<div role="alert" class="alert alert-error">
<.icon name="hero-exclamation-circle" class="size-5" aria-hidden="true" />

View file

@ -20,12 +20,12 @@ defmodule MvWeb.ImportLive.Components do
"""
def custom_fields_notice(assigns) do
~H"""
<div role="note" class="alert alert-info mb-4">
<div role="note" class="alert alert-info mb-4 w-xl">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<p class="text-sm mb-2">
{gettext(
"Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
"Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
)}
</p>
<p class="text-sm">
@ -48,7 +48,7 @@ defmodule MvWeb.ImportLive.Components do
def template_links(assigns) do
~H"""
<div class="mb-4">
<p class="text-sm text-base-content/70 mb-2">
<p class="mb-2">
{gettext("Download CSV templates:")}
</p>
<ul class="list-disc list-inside space-y-1">
@ -88,22 +88,20 @@ defmodule MvWeb.ImportLive.Components do
phx-submit="start_import"
data-testid="csv-upload-form"
>
<div class="form-control">
<label for="csv_file" class="label">
<span class="label-text">
{gettext("CSV File")}
</span>
<fieldset class="mb-2 fieldset w-md">
<label for="csv_file">
<span class="mb-1 label">{gettext("CSV File")}</span>
</label>
<.live_file_input
upload={@uploads.csv_file}
id="csv_file"
class="file-input file-input-bordered w-full"
class="file-input file-input-bordered"
aria-describedby="csv_file_help"
/>
<p class="label-text-alt mt-1" id="csv_file_help">
<p class="text-sm text-base-content/60 mt-2" id="csv_file_help">
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
</p>
</div>
</fieldset>
<.button
type="submit"

View file

@ -16,8 +16,8 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
- `on_cancel` - Callback function to call when form is cancelled
## Note
Member fields are technical fields that cannot be changed (name, value_type, description, required).
Only the visibility (show_in_overview) can be modified.
Member fields are technical fields that cannot be changed (name, value_type).
Visibility (show_in_overview) and required flag are stored in Settings and can be modified.
"""
use MvWeb, :live_component
@ -27,14 +27,13 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
alias MvWeb.Helpers.FieldTypeFormatter
alias MvWeb.Translations.MemberFields
@required_fields [:first_name, :last_name, :email]
@impl true
def render(assigns) do
assigns =
assigns
|> assign(:field_attributes, get_field_attributes(assigns.member_field))
|> assign(:is_email_field?, assigns.member_field == :email)
|> assign(:vereinfacht_required_field?, vereinfacht_required_field?(assigns))
|> assign(:field_label, MemberFields.label(assigns.member_field))
~H"""
@ -117,89 +116,64 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
</fieldset>
</div>
<div
:if={@is_email_field?}
class="tooltip tooltip-right"
data-tip={gettext("This is a technical field and cannot be changed")}
aria-label={gettext("This is a technical field and cannot be changed")}
>
<fieldset class="mb-2 fieldset">
<label>
<span class="mb-1 label flex items-center gap-2">
{gettext("Description")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/>
</span>
<input
type="text"
name={@form[:description].name}
id={@form[:description].id}
value={@form[:description].value}
disabled
readonly
class="w-full input"
/>
</label>
</fieldset>
</div>
<.input
:if={not @is_email_field?}
field={@form[:description]}
type="text"
label={gettext("Description")}
disabled={@is_email_field?}
readonly={@is_email_field?}
/>
<div
:if={@is_email_field?}
class="tooltip tooltip-right"
data-tip={gettext("This is a technical field and cannot be changed")}
aria-label={gettext("This is a technical field and cannot be changed")}
>
<fieldset class="mb-2 fieldset">
<label>
<input type="hidden" name={@form[:required].name} value="false" disabled />
<span class="label flex items-center gap-2">
<input
type="checkbox"
name={@form[:required].name}
id={@form[:required].id}
value="true"
checked={@form[:required].value}
disabled
readonly
class="checkbox checkbox-sm"
/>
<span class="flex items-center gap-2">
{gettext("Required")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
<%!-- Line break before Required / Show in overview block --%>
<div class="mt-4">
<%!-- Required: disabled for email (always required) or Vereinfacht-required fields when integration is active --%>
<div
:if={@is_email_field? or @vereinfacht_required_field?}
class="tooltip tooltip-right"
data-tip={
if(@is_email_field?,
do: gettext("This is a technical field and cannot be changed"),
else: gettext("Required for Vereinfacht integration and cannot be disabled.")
)
}
aria-label={
if(@is_email_field?,
do: gettext("This is a technical field and cannot be changed"),
else: gettext("Required for Vereinfacht integration and cannot be disabled.")
)
}
>
<fieldset class="mb-2 fieldset">
<label>
<input type="hidden" name={@form[:required].name} value="true" />
<span class="label flex items-center gap-2">
<input
type="checkbox"
name={@form[:required].name}
id={@form[:required].id}
value="true"
checked={@form[:required].value}
disabled
readonly
class="checkbox checkbox-sm"
/>
<span class="flex items-center gap-2">
{gettext("Required")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/>
</span>
</span>
</span>
</label>
</fieldset>
</div>
<.input
:if={not @is_email_field?}
field={@form[:required]}
type="checkbox"
label={gettext("Required")}
disabled={@is_email_field?}
readonly={@is_email_field?}
/>
</label>
</fieldset>
</div>
<.input
:if={not @is_email_field? and not @vereinfacht_required_field?}
field={@form[:required]}
type="checkbox"
label={gettext("Required")}
/>
<.input
field={@form[:show_in_overview]}
type="checkbox"
label={gettext("Show in overview")}
/>
<.input
field={@form[:show_in_overview]}
type="checkbox"
label={gettext("Show in overview")}
/>
</div>
<div class="justify-end mt-4 card-actions">
<.button type="button" phx-click="cancel" phx-target={@myself}>
@ -225,24 +199,35 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
@impl true
def handle_event("validate", %{"member_field" => member_field_params}, socket) do
# For member fields, we only validate show_in_overview
# Other fields are read-only or derived from the Member Resource
form = socket.assigns.form
updated_params =
member_field_params
|> Map.put(
"show_in_overview",
# Unchecked checkboxes are not in params; preserve current form value when key is missing
show_in_overview =
if Map.has_key?(member_field_params, "show_in_overview") do
TypeParsers.parse_boolean(member_field_params["show_in_overview"])
)
|> Map.put("name", form.source["name"])
|> Map.put("value_type", form.source["value_type"])
|> Map.put("description", form.source["description"])
|> Map.put("required", form.source["required"])
else
form.source["show_in_overview"]
end
required =
socket.assigns.vereinfacht_required_field? ||
if Map.has_key?(member_field_params, "required") do
TypeParsers.parse_boolean(member_field_params["required"])
else
form.source["required"]
end
# Merge so we keep name/value_type and have current checkbox state; use as new form source
merged_source =
form.source
|> Map.merge(%{
"show_in_overview" => show_in_overview,
"required" => required,
"name" => form.source["name"],
"value_type" => form.source["value_type"]
})
updated_form =
form
|> Map.put(:value, updated_params)
to_form(merged_source, as: "member_field")
|> Map.put(:errors, [])
{:noreply, assign(socket, form: updated_form)}
@ -250,23 +235,36 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
@impl true
def handle_event("save", %{"member_field" => member_field_params}, socket) do
# Only show_in_overview can be changed for member fields
show_in_overview = TypeParsers.parse_boolean(member_field_params["show_in_overview"])
form = socket.assigns.form
# Unchecked checkboxes are not in submit params; use form source when key missing
show_in_overview =
if Map.has_key?(member_field_params, "show_in_overview") do
TypeParsers.parse_boolean(member_field_params["show_in_overview"])
else
form.source["show_in_overview"]
end
required =
socket.assigns.vereinfacht_required_field? ||
if Map.has_key?(member_field_params, "required") do
TypeParsers.parse_boolean(member_field_params["required"])
else
form.source["required"]
end
field_string = Atom.to_string(socket.assigns.member_field)
# Use atomic action to update only this single field
# This prevents lost updates in concurrent scenarios
case Membership.update_single_member_field_visibility(
case Membership.update_single_member_field(
socket.assigns.settings,
field: field_string,
show_in_overview: show_in_overview
show_in_overview: show_in_overview,
required: required
) do
{:ok, _updated_settings} ->
socket.assigns.on_save.(socket.assigns.member_field, "update")
{:noreply, socket}
{:error, error} ->
# Add error to form
form =
socket.assigns.form
|> Map.put(:errors, [
@ -288,16 +286,29 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
defp assign_form(%{assigns: %{member_field: member_field, settings: settings}} = socket) do
field_attributes = get_field_attributes(member_field)
visibility_config = settings.member_field_visibility || %{}
normalized_config = VisibilityConfig.normalize(visibility_config)
show_in_overview = Map.get(normalized_config, member_field, true)
required_config = settings.member_field_required || %{}
normalized_visibility = VisibilityConfig.normalize(visibility_config)
normalized_required = VisibilityConfig.normalize(required_config)
show_in_overview = Map.get(normalized_visibility, member_field, true)
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
# Persist in socket so validate/save can enforce server-side without relying on render assigns
socket =
assign(
socket,
:vereinfacht_required_field?,
vereinfacht_required_field?(%{member_field: member_field})
)
# Email always required; Vereinfacht-required fields when integration active; else from settings
required =
member_field == :email ||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(member_field)) ||
Map.get(normalized_required, member_field, false)
# Create a manual form structure with string keys
# Note: immutable is not included as it's not editable for member fields
form_data = %{
"name" => MemberFields.label(member_field),
"value_type" => FieldTypeFormatter.format(field_attributes.value_type),
"description" => field_attributes.description || "",
"required" => field_attributes.required,
"required" => required,
"show_in_overview" => show_in_overview
}
@ -307,24 +318,14 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
end
defp get_field_attributes(field) when is_atom(field) do
# Get attribute info from Member Resource
alias Ash.Resource.Info
case Info.attribute(Mv.Membership.Member, field) do
nil ->
# Fallback for fields not in resource (shouldn't happen with Constants)
%{
value_type: :string,
description: nil,
required: field in @required_fields
}
%{value_type: :string}
attribute ->
%{
value_type: attribute.type,
description: nil,
required: not attribute.allow_nil?
}
%{value_type: attribute.type}
end
end
@ -335,4 +336,9 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
defp format_error(error) do
inspect(error)
end
defp vereinfacht_required_field?(assigns) do
Mv.Config.vereinfacht_configured?() &&
Mv.Constants.vereinfacht_required_field?(assigns.member_field)
end
end

View file

@ -22,7 +22,6 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
assigns =
assigns
|> assign(:member_fields, get_member_fields_with_visibility(assigns.settings))
|> assign(:required?, &required?/1)
~H"""
<div id={@id}>
@ -62,22 +61,15 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
{format_value_type(field_data.field)}
</:col>
<:col :let={{_field_name, field_data}} label={gettext("Description")}>
{field_data.description || ""}
</:col>
<:col
:let={{_field_name, field_data}}
label={gettext("Required")}
class="max-w-[9.375rem] text-center"
>
<span
:if={@required?.(field_data.field)}
class="text-base-content font-semibold"
>
<span :if={field_data.required} class="text-base-content font-semibold">
{gettext("Required")}
</span>
<span :if={!@required?.(field_data.field)} class="text-base-content/70">
<span :if={!field_data.required} class="text-base-content/70">
{gettext("Optional")}
</span>
</:col>
@ -173,26 +165,35 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
{:error, _} ->
# Return a minimal struct-like map for fallback
# This is only used for initial rendering, actual settings will be loaded properly
%{member_field_visibility: %{}}
%{member_field_visibility: %{}, member_field_required: %{}}
end
end
defp get_member_fields_with_visibility(settings) do
member_fields = Mv.Constants.member_fields()
visibility_config = settings.member_field_visibility || %{}
required_config = settings.member_field_required || %{}
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
# Normalize visibility config keys to atoms
normalized_config = VisibilityConfig.normalize(visibility_config)
normalized_visibility = VisibilityConfig.normalize(visibility_config)
normalized_required = VisibilityConfig.normalize(required_config)
Enum.map(member_fields, fn field ->
show_in_overview = Map.get(normalized_config, field, true)
show_in_overview = Map.get(normalized_visibility, field, true)
# Email always required; Vereinfacht-required fields when integration active; else from settings
required =
field == :email ||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
Map.get(normalized_required, field, false)
attribute = Info.attribute(Mv.Membership.Member, field)
%{
field: field,
show_in_overview: show_in_overview,
value_type: (attribute && attribute.type) || :string,
description: nil
required: required,
value_type: (attribute && attribute.type) || :string
}
end)
|> Enum.map(fn field_data ->
@ -206,14 +207,4 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
attribute -> FieldTypeFormatter.format(attribute.type)
end
end
# Check if a field is required by checking the actual attribute definition
defp required?(field) when is_atom(field) do
case Info.attribute(Mv.Membership.Member, field) do
nil -> false
attribute -> not attribute.allow_nil?
end
end
defp required?(_), do: false
end

View file

@ -23,6 +23,8 @@ defmodule MvWeb.MemberLive.Form do
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
alias Mv.Membership
alias Mv.Membership.Helpers.VisibilityConfig
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers
@ -84,47 +86,81 @@ defmodule MvWeb.MemberLive.Form do
<%!-- Name Row --%>
<div class="flex gap-4">
<div class="w-48">
<.input field={@form[:first_name]} label={gettext("First Name")} />
<.input
field={@form[:first_name]}
label={gettext("First Name")}
required={@member_field_required_map[:first_name]}
/>
</div>
<div class="w-48">
<.input field={@form[:last_name]} label={gettext("Last Name")} />
<.input
field={@form[:last_name]}
label={gettext("Last Name")}
required={@member_field_required_map[:last_name]}
/>
</div>
</div>
<%!-- Address Row --%>
<%!-- Address: Country, Postal Code, City in one row --%>
<div class="flex gap-4">
<div class="flex-1">
<.input field={@form[:street]} label={gettext("Street")} />
</div>
<div class="w-16">
<.input field={@form[:house_number]} label={gettext("Nr.")} />
<div class="w-48">
<.input field={@form[:country]} label={gettext("Country")} />
</div>
<div class="w-24">
<.input field={@form[:postal_code]} label={gettext("Postal Code")} />
<.input
field={@form[:postal_code]}
label={gettext("Postal Code")}
required={@member_field_required_map[:postal_code]}
/>
</div>
<div class="w-32">
<div class="w-48">
<.input field={@form[:city]} label={gettext("City")} />
</div>
</div>
<%!-- Street and Nr. below --%>
<div class="flex gap-4">
<div class="w-64">
<.input field={@form[:street]} label={gettext("Street")} />
</div>
<div class="w-24">
<.input field={@form[:house_number]} label={gettext("Nr.")} />
</div>
</div>
<%!-- Email --%>
<div>
<div class="w-64">
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
</div>
<%!-- Membership Dates Row --%>
<div class="flex gap-4">
<div class="w-36">
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />
<.input
field={@form[:join_date]}
label={gettext("Join Date")}
type="date"
required={@member_field_required_map[:join_date]}
/>
</div>
<div class="w-36">
<.input field={@form[:exit_date]} label={gettext("Exit Date")} type="date" />
<.input
field={@form[:exit_date]}
label={gettext("Exit Date")}
type="date"
required={@member_field_required_map[:exit_date]}
/>
</div>
</div>
<%!-- Notes --%>
<div>
<.input field={@form[:notes]} label={gettext("Notes")} type="textarea" />
<.input
field={@form[:notes]}
label={gettext("Notes")}
type="textarea"
required={@member_field_required_map[:notes]}
/>
</div>
</div>
</.form_section>
@ -254,6 +290,9 @@ defmodule MvWeb.MemberLive.Form do
# Load available membership fee types
available_fee_types = load_available_fee_types(member, actor)
# Load settings to know which member fields are required (for asterisk/tooltip)
member_field_required_map = get_member_field_required_map()
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
@ -263,9 +302,38 @@ defmodule MvWeb.MemberLive.Form do
|> assign(:page_title, page_title)
|> assign(:available_fee_types, available_fee_types)
|> assign(:interval_warning, nil)
|> assign(:member_field_required_map, member_field_required_map)
|> assign_form()}
end
defp get_member_field_required_map do
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
case Membership.get_settings() do
{:ok, settings} ->
required_config = settings.member_field_required || %{}
normalized = VisibilityConfig.normalize(required_config)
Mv.Constants.member_fields()
|> Enum.map(fn field ->
required =
field == :email ||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
Map.get(normalized, field, false)
{field, required}
end)
|> Map.new()
{:error, _} ->
# Email always required; Vereinfacht fields when integration active
Map.new(Mv.Constants.member_fields(), fn f ->
{f,
f == :email || (vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(f))}
end)
end
end
defp return_to("show"), do: "show"
defp return_to(_), do: "index"
@ -319,11 +387,40 @@ defmodule MvWeb.MemberLive.Form do
socket =
socket
|> put_flash(:info, flash_message)
|> maybe_put_vereinfacht_sync_flash(member.id)
|> push_navigate(to: return_path(socket.assigns.return_to, member))
{:noreply, socket}
end
defp maybe_put_vereinfacht_sync_flash(socket, member_id) do
case Mv.Vereinfacht.SyncFlash.take(to_string(member_id)) do
{:warning, message} ->
put_flash(socket, :warning, translate_vereinfacht_flash(message))
{:ok, _message} ->
# Optionally show sync success; for now we keep only the main success message
socket
nil ->
socket
end
end
defp translate_vereinfacht_flash(message) when is_binary(message) do
prefix = "Vereinfacht: "
if String.starts_with?(message, prefix) do
detail = message |> String.trim_leading(prefix) |> String.trim()
Gettext.dgettext(MvWeb.Gettext, "default", "Vereinfacht: %{detail}",
detail: Gettext.dgettext(MvWeb.Gettext, "default", detail)
)
else
Gettext.dgettext(MvWeb.Gettext, "default", message)
end
end
defp handle_save_error(socket, form) do
# Always show a flash message when save fails
# Field-level validation errors are displayed in form fields, but flash provides additional feedback
@ -606,6 +703,7 @@ defmodule MvWeb.MemberLive.Form do
|> extract_form_value(form, :house_number, &to_string/1)
|> extract_form_value(form, :postal_code, &to_string/1)
|> extract_form_value(form, :city, &to_string/1)
|> extract_form_value(form, :country, &to_string/1)
|> extract_form_value(form, :join_date, &format_date_value/1)
|> extract_form_value(form, :exit_date, &format_date_value/1)
|> extract_form_value(form, :notes, &to_string/1)

View file

@ -615,7 +615,9 @@ defmodule MvWeb.MemberLive.Index do
# -----------------------------------------------------------------
@impl true
def handle_params(params, _url, socket) do
def handle_params(params, url, socket) do
url = url || request_url_from_socket(socket)
params = merge_fields_param_from_uri(params, url)
prev_sig = build_signature(socket)
fields_in_url? =
@ -625,20 +627,7 @@ defmodule MvWeb.MemberLive.Index do
end
url_selection = FieldSelection.parse_from_url(params)
merged_selection =
FieldSelection.merge_sources(
url_selection,
socket.assigns.user_field_selection,
%{}
)
final_selection =
FieldVisibility.merge_with_global_settings(
merged_selection,
socket.assigns.settings,
socket.assigns.all_custom_fields
)
final_selection = compute_final_field_selection(fields_in_url?, url_selection, socket)
visible_member_fields =
final_selection
@ -682,6 +671,19 @@ defmodule MvWeb.MemberLive.Index do
|> update_selection_assigns()
end
# Update sort components after rendering
socket =
if socket.assigns[:sort_needs_update] do
old_field = socket.assigns[:previous_sort_field] || socket.assigns.sort_field
socket
|> update_sort_components(old_field, socket.assigns.sort_field, socket.assigns.sort_order)
|> assign(:sort_needs_update, false)
|> assign(:previous_sort_field, nil)
else
socket
end
{:noreply, socket}
end
@ -815,6 +817,70 @@ defmodule MvWeb.MemberLive.Index do
add_boolean_filters(base_params, boolean_filters)
end
defp compute_final_field_selection(true, url_selection, socket) do
only_url =
FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields)
visible_members = FieldVisibility.get_visible_member_fields(only_url)
visible_custom = FieldVisibility.get_visible_custom_fields(only_url)
if visible_members == [] and visible_custom == [] do
# URL had only invalid field names; fall back to session + global.
compute_final_field_selection(false, url_selection, socket)
else
only_url
end
end
defp compute_final_field_selection(false, url_selection, socket) do
merged =
FieldSelection.merge_sources(
url_selection,
socket.assigns.user_field_selection,
%{}
)
FieldVisibility.merge_with_global_settings(
merged,
socket.assigns.settings,
socket.assigns.all_custom_fields
)
end
# On full page load conn.params has no query string; read "fields" from URI so column visibility is restored.
defp request_url_from_socket(socket) do
case socket.private[:connect_info] do
%Plug.Conn{} = conn -> Plug.Conn.request_url(conn)
_ -> nil
end
end
defp merge_fields_param_from_uri(params, nil), do: params
defp merge_fields_param_from_uri(params, %URI{query: query}) when is_binary(query) do
case URI.decode_query(query)["fields"] do
nil -> params
value -> Map.put(params, "fields", value)
end
end
defp merge_fields_param_from_uri(params, %URI{}), do: params
defp merge_fields_param_from_uri(params, url) when is_binary(url) do
case URI.parse(url).query do
nil ->
params
q ->
case URI.decode_query(q)["fields"] do
nil -> params
value -> Map.put(params, "fields", value)
end
end
end
defp merge_fields_param_from_uri(params, _), do: params
defp build_base_params(query, sort_field, sort_order) do
%{
"query" => query || "",
@ -900,6 +966,15 @@ defmodule MvWeb.MemberLive.Index do
query =
Ash.Query.load(query, groups: [:id, :name, :slug])
# Load membership_fee_type when the column is visible or when sorting by it
query =
if :membership_fee_type in socket.assigns.member_fields_visible or
socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do
Ash.Query.load(query, membership_fee_type: [:id, :name])
else
query
end
query = apply_search_filter(query, search_query)
query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups])
@ -940,9 +1015,10 @@ defmodule MvWeb.MemberLive.Index do
)
# Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked)
# Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status
members =
if sort_after_load and
socket.assigns.sort_field not in FieldVisibility.computed_member_fields() do
socket.assigns.sort_field != :membership_fee_status do
sort_members_in_memory(
members,
socket.assigns.sort_field,
@ -1044,27 +1120,25 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false}
defp maybe_sort(query, field, order, _custom_fields) do
if computed_field?(field) do
# :groups is in computed_member_fields() but can be sorted in-memory
# Only :membership_fee_status should be blocked from sorting
if field == :membership_fee_status or field == "membership_fee_status" do
{query, false}
else
apply_sort_to_query(query, field, order)
end
end
defp computed_field?(field) do
computed_atoms = FieldVisibility.computed_member_fields()
computed_strings = Enum.map(computed_atoms, &Atom.to_string/1)
(is_atom(field) and field in computed_atoms) or
(is_binary(field) and field in computed_strings)
end
defp apply_sort_to_query(query, field, order) do
cond do
# Groups sort -> after load (in memory)
field in [:groups, "groups"] ->
{query, true}
# Membership fee type sort -> by related name at DB
field in [:membership_fee_type, "membership_fee_type"] ->
{Ash.Query.sort(query, [{"membership_fee_type.name", order}]), false}
# Custom field sort -> after load
custom_field_sort?(field) ->
{query, true}
@ -1086,13 +1160,19 @@ defmodule MvWeb.MemberLive.Index do
end
defp valid_sort_field?(field) when is_atom(field) do
if field in FieldVisibility.computed_member_fields(),
do: false,
else: valid_sort_field_db_or_custom?(field)
# :groups is in computed_member_fields() but can be sorted
# Only :membership_fee_status should be blocked
if field == :membership_fee_status do
false
else
valid_sort_field_db_or_custom?(field)
end
end
defp valid_sort_field?(field) when is_binary(field) do
if field in Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) do
# "groups" is in computed_member_fields() but can be sorted
# Only "membership_fee_status" should be blocked
if field == "membership_fee_status" do
false
else
valid_sort_field_db_or_custom?(field)
@ -1104,11 +1184,16 @@ defmodule MvWeb.MemberLive.Index do
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
non_sortable_fields = [:notes]
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
field in valid_fields or custom_field_sort?(field) or field == :groups
field in valid_fields or custom_field_sort?(field) or field in [:groups, :membership_fee_type]
end
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
normalized = if field == "groups", do: :groups, else: safe_member_field_atom_only(field)
normalized =
cond do
field == "groups" -> :groups
field == "membership_fee_type" -> :membership_fee_type
true -> safe_member_field_atom_only(field)
end
(normalized != nil and valid_sort_field_db_or_custom?(normalized)) or
custom_field_sort?(field)
@ -1249,10 +1334,13 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do
field = determine_field(socket.assigns.sort_field, sf)
order = determine_order(socket.assigns.sort_order, so)
old_field = socket.assigns.sort_field
socket
|> assign(:sort_field, field)
|> assign(:sort_order, order)
|> assign(:sort_needs_update, old_field != field or socket.assigns.sort_order != order)
|> assign(:previous_sort_field, old_field)
end
defp maybe_update_sort(socket, _), do: socket
@ -1261,17 +1349,27 @@ defmodule MvWeb.MemberLive.Index do
defp determine_field(default, nil), do: default
defp determine_field(default, sf) when is_binary(sf) do
computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1)
# Handle "groups" specially - it's in computed_member_fields() but can be sorted
if sf == "groups" do
:groups
else
computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1)
if sf in computed_strings,
do: default,
else: determine_field_after_computed_check(default, sf)
if sf in computed_strings,
do: default,
else: determine_field_after_computed_check(default, sf)
end
end
defp determine_field(default, sf) when is_atom(sf) do
if sf in FieldVisibility.computed_member_fields(),
do: default,
else: determine_field_after_computed_check(default, sf)
# Handle :groups specially - it's in computed_member_fields() but can be sorted
if sf == :groups do
:groups
else
if sf in FieldVisibility.computed_member_fields(),
do: default,
else: determine_field_after_computed_check(default, sf)
end
end
defp determine_field(default, _), do: default
@ -1620,6 +1718,12 @@ defmodule MvWeb.MemberLive.Index do
FieldVisibility.computed_member_fields()
|> Enum.filter(&(&1 in member_fields_computed))
member_fields_with_groups =
build_export_member_fields_list(
ordered_member_fields_db,
socket.assigns[:member_fields_visible]
)
# Order custom fields like the table (same as dynamic_cols / all_custom_fields order)
ordered_custom_field_ids =
socket.assigns.all_custom_fields
@ -1628,14 +1732,20 @@ defmodule MvWeb.MemberLive.Index do
%{
selected_ids: socket.assigns.selected_members |> MapSet.to_list(),
member_fields: Enum.map(ordered_member_fields_db, &Atom.to_string/1),
member_fields:
Enum.map(member_fields_with_groups, fn
f when is_atom(f) -> Atom.to_string(f)
f when is_binary(f) -> f
end),
computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1),
custom_field_ids: ordered_custom_field_ids,
column_order:
export_column_order(
ordered_member_fields_db,
ordered_computed_fields,
ordered_custom_field_ids
ordered_custom_field_ids,
:membership_fee_type in socket.assigns[:member_fields_visible],
:groups in socket.assigns[:member_fields_visible]
),
query: socket.assigns[:query] || nil,
sort_field: export_sort_field(socket.assigns[:sort_field]),
@ -1646,6 +1756,41 @@ defmodule MvWeb.MemberLive.Index do
}
end
defp expand_db_string_for_export(f, membership_fee_type_visible, computed_strings) do
if f == "membership_fee_start_date" do
extra =
if(membership_fee_type_visible, do: ["membership_fee_type"], else: []) ++
if "membership_fee_status" in computed_strings, do: ["membership_fee_status"], else: []
[f] ++ extra
else
[f]
end
end
defp build_export_member_fields_list(ordered_db, member_fields_visible) do
with_extras =
Enum.flat_map(ordered_db, fn f ->
if f == :membership_fee_start_date and
:membership_fee_type in (member_fields_visible || []) do
[f, :membership_fee_type]
else
[f]
end
end)
# If fee type is visible but start_date was not in the list, append it
with_extras =
if :membership_fee_type in (member_fields_visible || []) and
:membership_fee_type not in with_extras do
with_extras ++ [:membership_fee_type]
else
with_extras
end
if :groups in (member_fields_visible || []), do: with_extras ++ [:groups], else: with_extras
end
defp export_cycle_status_filter(nil), do: nil
defp export_cycle_status_filter(:paid), do: "paid"
defp export_cycle_status_filter(:unpaid), do: "unpaid"
@ -1661,31 +1806,41 @@ defmodule MvWeb.MemberLive.Index do
defp export_sort_order(o) when is_binary(o), do: o
# Build a single ordered list that matches the table order:
# - DB fields in Mv.Constants.member_fields() order (already pre-filtered as ordered_member_fields_db)
# - computed fields inserted at the correct position (membership_fee_status after membership_fee_start_date)
# - membership_fee_type and membership_fee_status inserted after membership_fee_start_date when visible
# - groups appended before custom fields when visible
# - custom fields appended in the same order as table (already ordered_custom_field_ids)
defp export_column_order(
ordered_member_fields_db,
ordered_computed_fields,
ordered_custom_field_ids
ordered_custom_field_ids,
membership_fee_type_visible,
groups_visible
) do
db_strings = Enum.map(ordered_member_fields_db, &Atom.to_string/1)
computed_strings = Enum.map(ordered_computed_fields, &Atom.to_string/1)
# Place membership_fee_status right after membership_fee_start_date if present in export
db_with_computed =
Enum.flat_map(db_strings, fn f ->
if f == "membership_fee_start_date" and "membership_fee_status" in computed_strings do
[f, "membership_fee_status"]
else
[f]
end
end)
# Place membership_fee_type and membership_fee_status after membership_fee_start_date when present
db_with_extras =
Enum.flat_map(
db_strings,
&expand_db_string_for_export(&1, membership_fee_type_visible, computed_strings)
)
# If fee type is visible but start_date was not in the list, append it before computed/groups
db_with_extras =
if membership_fee_type_visible and "membership_fee_type" not in db_with_extras do
db_with_extras ++ ["membership_fee_type"]
else
db_with_extras
end
# Any remaining computed fields not inserted above (future-proof)
remaining_computed =
computed_strings
|> Enum.reject(&(&1 in db_with_computed))
|> Enum.reject(&(&1 in db_with_extras))
db_with_computed ++ remaining_computed ++ ordered_custom_field_ids
result = db_with_extras ++ remaining_computed
result = if groups_visible, do: result ++ ["groups"], else: result
result ++ ordered_custom_field_ids
end
end

View file

@ -223,6 +223,24 @@
>
{member.notes}
</:col>
<:col
:let={member}
:if={:country in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_country}
field={:country}
label={gettext("Country")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.country}
</:col>
<:col
:let={member}
:if={:city in @member_fields_visible}
@ -313,6 +331,28 @@
>
{MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)}
</:col>
<:col
:let={member}
:if={:membership_fee_type in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_membership_fee_type}
field={:membership_fee_type}
label={gettext("Fee Type")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
<%= if member.membership_fee_type do %>
{member.membership_fee_type.name}
<% else %>
<span class="text-base-content/50">—</span>
<% end %>
</:col>
<:col
:let={member}
:if={:membership_fee_status in @member_fields_visible}
@ -331,6 +371,7 @@
</:col>
<:col
:let={member}
:if={:groups in @member_fields_visible}
label={
~H"""
<.live_component

View file

@ -28,7 +28,8 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
alias Mv.Membership.Helpers.VisibilityConfig
# Single UI key for "Membership Fee Status"; only this appears in the dropdown.
@pseudo_member_fields [:membership_fee_status]
# Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table).
@pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups]
# Export/API may accept this as alias; must not appear in the UI options list.
@export_only_alias :payment_status
@ -63,6 +64,25 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
member_fields ++ custom_field_names
end
@doc """
Builds field selection from URL only: fields in `url_selection` are visible, all others false.
Use when `?fields=...` is in the URL so column visibility is not merged with global settings.
"""
@spec selection_from_url_only(%{String.t() => boolean()}, [struct()]) :: %{
String.t() => boolean()
}
def selection_from_url_only(url_selection, custom_fields) when is_map(url_selection) do
all_fields = get_all_available_fields(custom_fields)
Enum.reduce(all_fields, %{}, fn field, acc ->
field_string = field_to_string(field)
visible = Map.get(url_selection, field_string, false)
Map.put(acc, field_string, visible)
end)
end
def selection_from_url_only(_, _), do: %{}
@doc """
Merges user field selection with global settings.
@ -201,7 +221,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
"""
@spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()]
def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do
computed_set = MapSet.new(@pseudo_member_fields)
computed_set = MapSet.new([:membership_fee_status])
field_selection
|> Enum.filter(fn {field_string, visible} ->

View file

@ -256,6 +256,7 @@ defmodule MvWeb.MemberLive.Show do
id={"membership-fees-#{@member.id}"}
member={@member}
current_user={@current_user}
vereinfacht_receipts={@vereinfacht_receipts}
/>
<% end %>
</Layouts.app>
@ -264,7 +265,10 @@ defmodule MvWeb.MemberLive.Show do
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :active_tab, :contact)}
{:ok,
socket
|> assign(:active_tab, :contact)
|> assign(:vereinfacht_receipts, nil)}
end
@impl true
@ -316,6 +320,16 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :active_tab, :membership_fees)}
end
def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do
response =
case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do
{:ok, receipts} -> {:ok, receipts}
{:error, reason} -> {:error, reason}
end
{:noreply, assign(socket, :vereinfacht_receipts, response)}
end
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
@impl true
def handle_info({:put_flash, type, message}, socket) do
@ -437,8 +451,8 @@ defmodule MvWeb.MemberLive.Show do
|> Enum.filter(&(&1 && &1 != ""))
|> Enum.join(" ")
[street_part, city_part]
|> Enum.filter(&(&1 != ""))
[member.country, street_part, city_part]
|> Enum.filter(&(&1 && &1 != ""))
|> Enum.join(", ")
|> case do
"" -> nil

View file

@ -50,6 +50,90 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<% end %>
</div>
<%!-- Vereinfacht: contact info when synced, or warning when API is configured but no contact --%>
<%= if Mv.Config.vereinfacht_configured?() do %>
<%= if @vereinfacht_contact_present do %>
<div class="mb-4">
<div class="flex flex-col gap-2">
<.link
:if={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)}
href={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)}
target="_blank"
rel="noopener noreferrer"
class="link link-accent underline inline-flex items-center gap-1 w-fit"
>
{gettext("View contact in Vereinfacht")}
<.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" />
</.link>
<div>
<button
type="button"
phx-click="load_vereinfacht_receipts"
phx-value-contact_id={@member.vereinfacht_contact_id}
class="btn btn-sm btn-ghost"
>
{gettext("Show bookings/receipts from Vereinfacht")}
</button>
</div>
<%= if @vereinfacht_receipts do %>
<div
class="mt-2 rounded border border-base-300 bg-base-200 p-3 overflow-x-auto max-h-96 overflow-y-auto"
tabindex="0"
role="region"
aria-label={gettext("Vereinfacht receipts")}
>
<%= if match?({:ok, _}, @vereinfacht_receipts) do %>
<% {_, receipts} = @vereinfacht_receipts %>
<%= if receipts == [] do %>
<p class="text-sm text-base-content/70">{gettext("No receipts")}</p>
<% else %>
<% cols = receipt_display_columns(receipts) %>
<table class="table table-xs table-pin-rows">
<thead>
<tr>
<%= for {_key, translated_label} <- cols do %>
<th>{translated_label}</th>
<% end %>
</tr>
</thead>
<tbody>
<%= for r <- receipts do %>
<tr>
<%= for {col_key, _header_key} <- cols do %>
<td>{format_receipt_cell(col_key, r[col_key])}</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
<% end %>
<% else %>
<% {:error, reason} = @vereinfacht_receipts %>
<p class="text-sm text-error">
{gettext("Error loading receipts: %{reason}",
reason: format_vereinfacht_error(reason)
)}
</p>
<% end %>
</div>
<% end %>
</div>
</div>
<% else %>
<div class="mb-4 rounded-lg border border-warning/50 bg-warning/10 p-3">
<p class="text-warning font-medium flex items-center gap-2">
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
{gettext("No Vereinfacht contact exists for this member.")}
</p>
<p class="text-sm text-base-content/70 mt-1">
{gettext(
"Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
)}
</p>
</div>
<% end %>
<% end %>
<%!-- Action Buttons (only when user has permission) --%>
<div class="flex gap-2 mb-4">
<.button
@ -130,47 +214,49 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</:col>
<:action :let={cycle}>
<div class="flex gap-1">
<div class="flex gap-2">
<%= if @can_update_cycle do %>
<button
:if={cycle.status != :paid}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="paid"
phx-target={@myself}
class="btn btn-sm btn-success"
title={gettext("Mark as paid")}
>
<.icon name="hero-check-circle" class="size-4" />
{gettext("Paid")}
</button>
<button
:if={cycle.status != :suspended}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="suspended"
phx-target={@myself}
class="btn btn-sm btn-outline btn-warning"
title={gettext("Mark as suspended")}
>
<.icon name="hero-pause-circle" class="size-4" />
{gettext("Suspended")}
</button>
<button
:if={cycle.status != :unpaid}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="unpaid"
phx-target={@myself}
class="btn btn-sm btn-error"
title={gettext("Mark as unpaid")}
>
<.icon name="hero-x-circle" class="size-4" />
{gettext("Unpaid")}
</button>
<div class="join">
<button
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="paid"
phx-target={@myself}
class={cycle_status_btn_class(cycle.status, :paid)}
aria-pressed={cycle.status == :paid}
title={gettext("Mark as paid")}
>
<.icon name="hero-check-circle" class="size-4" />
{gettext("Paid")}
</button>
<button
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="suspended"
phx-target={@myself}
class={cycle_status_btn_class(cycle.status, :suspended)}
aria-pressed={cycle.status == :suspended}
title={gettext("Mark as suspended")}
>
<.icon name="hero-pause-circle" class="size-4" />
{gettext("Suspended")}
</button>
<button
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="unpaid"
phx-target={@myself}
class={cycle_status_btn_class(cycle.status, :unpaid)}
aria-pressed={cycle.status == :unpaid}
title={gettext("Mark as unpaid")}
>
<.icon name="hero-x-circle" class="size-4" />
{gettext("Unpaid")}
</button>
</div>
<% end %>
<%= if @can_destroy_cycle do %>
<button
@ -431,6 +517,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:can_create_cycle, can_create_cycle)
|> assign(:can_destroy_cycle, can_destroy_cycle)
|> assign(:can_update_cycle, can_update_cycle)
|> assign(:vereinfacht_contact_present, present_contact_id?(member.vereinfacht_contact_id))
|> assign_new(:interval_warning, fn -> nil end)
|> assign_new(:editing_cycle, fn -> nil end)
|> assign_new(:deleting_cycle, fn -> nil end)
@ -439,7 +526,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign_new(:creating_cycle, fn -> false end)
|> assign_new(:create_cycle_date, fn -> nil end)
|> assign_new(:create_cycle_error, fn -> nil end)
|> assign_new(:regenerating, fn -> false end)}
|> assign_new(:regenerating, fn -> false end)
|> assign_new(:vereinfacht_receipts, fn -> nil end)}
end
@impl true
@ -997,6 +1085,156 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp format_create_cycle_period(_date, _interval), do: ""
defp present_contact_id?(nil), do: false
defp present_contact_id?(id) when is_binary(id), do: String.trim(id) != ""
defp present_contact_id?(_), do: false
defp format_vereinfacht_error({:http, status, detail}) when is_binary(detail),
do: "HTTP #{status} #{detail}"
defp format_vereinfacht_error({:http, status, _}), do: "HTTP #{status}"
defp format_vereinfacht_error(reason), do: inspect(reason)
# Ordered receipt columns: {api_key, gettext key for header}. Only columns present in data are shown.
@receipt_column_spec [
{:amount, "Amount"},
{:bookingDate, "Booking date"},
{:createdAt, "Created at"},
{:receiptType, "Receipt type"},
{:referenceNumber, "Reference number"},
{:status, "Status"},
{:updatedAt, "Updated at"}
]
defp receipt_display_columns(receipts) when is_list(receipts) do
keys_in_data = receipts |> Enum.flat_map(&Map.keys/1) |> MapSet.new()
Enum.filter(@receipt_column_spec, fn {key, _} -> MapSet.member?(keys_in_data, key) end)
|> Enum.map(fn {key, msgid} -> {key, Gettext.gettext(MvWeb.Gettext, msgid)} end)
end
defp format_receipt_cell(:amount, nil), do: ""
defp format_receipt_cell(:amount, val) when is_number(val) do
case Decimal.cast(val) do
{:ok, d} -> MembershipFeeHelpers.format_currency(d)
_ -> to_string(val)
end
end
defp format_receipt_cell(:amount, val) when is_binary(val) do
case Decimal.parse(val) do
{d, _} -> MembershipFeeHelpers.format_currency(d)
:error -> val
end
end
defp format_receipt_cell(:amount, val), do: to_string(val)
defp format_receipt_cell(:status, nil), do: ""
defp format_receipt_cell(:status, val) when is_binary(val) do
translate_receipt_status(val)
end
defp format_receipt_cell(:status, val), do: translate_receipt_status(to_string(val))
defp format_receipt_cell(:receiptType, nil), do: ""
defp format_receipt_cell(:receiptType, val) when is_binary(val) do
translate_receipt_type(val)
end
defp format_receipt_cell(:receiptType, val), do: translate_receipt_type(to_string(val))
defp format_receipt_cell(col_key, nil) when col_key in [:bookingDate, :createdAt, :updatedAt],
do: ""
defp format_receipt_cell(col_key, val) when col_key in [:bookingDate, :createdAt, :updatedAt] do
format_receipt_date(val)
end
defp format_receipt_cell(_col_key, val) when is_binary(val), do: val
defp format_receipt_cell(_col_key, val) when is_number(val), do: to_string(val)
defp format_receipt_cell(_col_key, val) when is_boolean(val),
do: if(val, do: gettext("Yes"), else: gettext("No"))
defp format_receipt_cell(_col_key, %Date{} = d), do: format_receipt_date_short(d)
defp format_receipt_cell(_col_key, val) when is_map(val) or is_list(val), do: Jason.encode!(val)
defp format_receipt_cell(_col_key, val), do: to_string(val)
defp format_receipt_date(%Date{} = d), do: format_receipt_date_short(d)
defp format_receipt_date(val) when is_binary(val) do
case parse_receipt_date(val) do
{:ok, d} -> format_receipt_date_short(d)
_ -> val
end
end
defp format_receipt_date(val), do: to_string(val)
# Parses ISO date or datetime string to Date (uses first 10 chars for datetime strings)
defp parse_receipt_date(val) when is_binary(val) do
date_str = if String.length(val) >= 10, do: String.slice(val, 0, 10), else: val
Date.from_iso8601(date_str)
end
# Format as "12. Dez. 2025" (day. abbreviated month. year) with translated month
defp format_receipt_date_short(%Date{day: day, month: month, year: year}) do
"#{day}. #{receipt_month_abbr(month)} #{year}"
end
defp receipt_month_abbr(1), do: gettext("Jan.")
defp receipt_month_abbr(2), do: gettext("Feb.")
defp receipt_month_abbr(3), do: gettext("Mar.")
defp receipt_month_abbr(4), do: gettext("Apr.")
defp receipt_month_abbr(5), do: gettext("May")
defp receipt_month_abbr(6), do: gettext("Jun.")
defp receipt_month_abbr(7), do: gettext("Jul.")
defp receipt_month_abbr(8), do: gettext("Aug.")
defp receipt_month_abbr(9), do: gettext("Sep.")
defp receipt_month_abbr(10), do: gettext("Oct.")
defp receipt_month_abbr(11), do: gettext("Nov.")
defp receipt_month_abbr(12), do: gettext("Dec.")
defp receipt_month_abbr(_), do: ""
# Translate API status values for display (extend as API returns more values)
defp translate_receipt_status("paid"), do: gettext("Paid")
defp translate_receipt_status("unpaid"), do: gettext("Unpaid")
defp translate_receipt_status("suspended"), do: gettext("Suspended")
defp translate_receipt_status("open"), do: gettext("Open")
defp translate_receipt_status("cancelled"), do: gettext("Cancelled")
defp translate_receipt_status("draft"), do: gettext("Draft")
defp translate_receipt_status("incompleted"), do: gettext("Incompleted")
defp translate_receipt_status("completed"), do: gettext("Completed")
defp translate_receipt_status("empty"), do: ""
defp translate_receipt_status(other), do: other
# Translate API receipt type values (extend as API returns more values)
defp translate_receipt_type("invoice"), do: gettext("Invoice")
defp translate_receipt_type("receipt"), do: gettext("Receipt")
defp translate_receipt_type("credit_note"), do: gettext("Credit note")
defp translate_receipt_type("credit"), do: gettext("Credit")
defp translate_receipt_type("expense"), do: gettext("Expense")
defp translate_receipt_type("income"), do: gettext("Income")
defp translate_receipt_type(other), do: other
# Returns CSS classes for a cycle status button.
# Active (current) status is highlighted with color and non-interactive;
# inactive buttons are neutral gray. Matches the filter button pattern.
defp cycle_status_btn_class(current_status, btn_status) do
base = "join-item btn btn-sm"
case {current_status == btn_status, btn_status} do
{true, :paid} -> "#{base} btn-success btn-active pointer-events-none"
{true, :suspended} -> "#{base} btn-warning btn-active pointer-events-none"
{true, :unpaid} -> "#{base} btn-error btn-active pointer-events-none"
_ -> base
end
end
# Helper component for section box
attr :title, :string, required: true
slot :inner_block, required: true

View file

@ -1,17 +1,23 @@
defmodule MvWeb.MembershipFeeSettingsLive do
@moduledoc """
LiveView for managing membership fee settings (Admin).
LiveView for membership fee settings and fee types (Admin).
Allows administrators to configure:
- Default membership fee type for new members
- Whether to include the joining cycle in membership fee generation
Combines:
- Global settings (default fee type, include joining cycle)
- Membership fee types table (CRUD links to new/edit routes; delete inline)
Examples and info are collapsible to save space.
"""
use MvWeb, :live_view
require Ash.Query
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership
alias Mv.Membership.Member
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true
def mount(_params, _session, socket) do
@ -23,11 +29,14 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|> Ash.Query.sort(name: :asc)
|> Ash.read!(domain: Mv.MembershipFees, actor: actor)
member_counts = load_member_counts(membership_fee_types, actor)
{:ok,
socket
|> assign(:page_title, gettext("Membership Fee Settings"))
|> assign(:settings, settings)
|> assign(:membership_fee_types, membership_fee_types)
|> assign(:member_counts, member_counts)
|> assign_form()}
end
@ -81,6 +90,51 @@ defmodule MvWeb.MembershipFeeSettingsLive do
end
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
actor = current_actor(socket)
case Ash.get(MembershipFeeType, id, domain: MembershipFees, actor: actor) do
{:ok, fee_type} ->
case Ash.destroy(fee_type, domain: MembershipFees, actor: actor) do
:ok ->
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
updated_counts = Map.delete(socket.assigns.member_counts, id)
{:noreply,
socket
|> assign(:membership_fee_types, updated_types)
|> assign(:member_counts, updated_counts)
|> put_flash(:info, gettext("Membership fee type deleted"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this membership fee type")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply, put_flash(socket, :error, gettext("Membership fee type not found"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to access this membership fee type")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
end
@impl true
def render(assigns) do
~H"""
@ -88,8 +142,13 @@ defmodule MvWeb.MembershipFeeSettingsLive do
<.header>
{gettext("Membership Fee Settings")}
<:subtitle>
{gettext("Configure global settings for membership fees.")}
{gettext("Configure global settings and fee types for membership fees.")}
</:subtitle>
<:actions>
<.button variant="primary" navigate={~p"/membership_fee_settings/new_fee_type"}>
<.icon name="hero-plus" /> {gettext("New Membership Fee Type")}
</.button>
</:actions>
</.header>
<div class="grid gap-6 lg:grid-cols-2">
@ -188,58 +247,169 @@ defmodule MvWeb.MembershipFeeSettingsLive do
</div>
</div>
<%!-- Examples Card --%>
<%!-- Examples Card (collapsible) --%>
<div class="card bg-base-200">
<div class="card-body">
<h2 class="card-title">
<.icon name="hero-light-bulb" class="size-5" />
{gettext("Examples")}
</h2>
<details class="group">
<summary class="card-title cursor-pointer list-none flex items-center gap-2">
<.icon name="hero-chevron-right" class="size-5 transition group-open:rotate-90" />
<.icon name="hero-light-bulb" class="size-5" />
{gettext("Examples")}
</summary>
<.example_section
title={gettext("Yearly Interval - Joining Cycle Included")}
joining_date="15.03.2023"
include_joining={true}
start_date="01.01.2023"
periods={["2023", "2024", "2025"]}
note={gettext("Member pays for the year they joined")}
/>
<div class="pt-4 space-y-4">
<.example_section
title={gettext("Yearly Interval - Joining Cycle Included")}
joining_date="15.03.2023"
include_joining={true}
start_date="01.01.2023"
periods={["2023", "2024", "2025"]}
note={gettext("Member pays for the year they joined")}
/>
<div class="divider"></div>
<div class="divider"></div>
<.example_section
title={gettext("Yearly Interval - Joining Cycle Excluded")}
joining_date="15.03.2023"
include_joining={false}
start_date="01.01.2024"
periods={["2024", "2025"]}
note={gettext("Member pays from the next full year")}
/>
<.example_section
title={gettext("Yearly Interval - Joining Cycle Excluded")}
joining_date="15.03.2023"
include_joining={false}
start_date="01.01.2024"
periods={["2024", "2025"]}
note={gettext("Member pays from the next full year")}
/>
<div class="divider"></div>
<div class="divider"></div>
<.example_section
title={gettext("Quarterly Interval - Joining Cycle Excluded")}
joining_date="15.05.2024"
include_joining={false}
start_date="01.07.2024"
periods={["Q3/2024", "Q4/2024", "Q1/2025"]}
note={gettext("Member pays from the next full quarter")}
/>
<.example_section
title={gettext("Quarterly Interval - Joining Cycle Excluded")}
joining_date="15.05.2024"
include_joining={false}
start_date="01.07.2024"
periods={["Q3/2024", "Q4/2024", "Q1/2025"]}
note={gettext("Member pays from the next full quarter")}
/>
<div class="divider"></div>
<div class="divider"></div>
<.example_section
title={gettext("Monthly Interval - Joining Cycle Included")}
joining_date="15.03.2024"
include_joining={true}
start_date="01.03.2024"
periods={["03/2024", "04/2024", "05/2024", "..."]}
note={gettext("Member pays from the joining month")}
/>
<.example_section
title={gettext("Monthly Interval - Joining Cycle Included")}
joining_date="15.03.2024"
include_joining={true}
start_date="01.03.2024"
periods={["03/2024", "04/2024", "05/2024", "..."]}
note={gettext("Member pays from the joining month")}
/>
</div>
</details>
</div>
</div>
</div>
<%!-- Fee Types Table --%>
<div class="mt-8">
<h2 class="text-lg font-semibold mb-4">{gettext("Membership Fee Types")}</h2>
<.table
id="membership_fee_types"
rows={@membership_fee_types}
row_id={fn mft -> "mft-#{mft.id}" end}
>
<:col :let={mft} label={gettext("Name")}>
<span class="font-medium">{mft.name}</span>
<p :if={mft.description} class="text-sm text-base-content/70">{mft.description}</p>
</:col>
<:col :let={mft} label={gettext("Amount")}>
<span class="font-mono">{MembershipFeeHelpers.format_currency(mft.amount)}</span>
</:col>
<:col :let={mft} label={gettext("Interval")}>
<span class="badge badge-outline">
{MembershipFeeHelpers.format_interval(mft.interval)}
</span>
</:col>
<:col :let={mft} label={gettext("Members")}>
<span class="badge badge-ghost">{get_member_count(mft, @member_counts)}</span>
</:col>
<:action :let={mft}>
<.link
navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
class="btn btn-ghost btn-xs"
aria-label={gettext("Edit membership fee type")}
>
<.icon name="hero-pencil" class="size-4" />
</.link>
</:action>
<:action :let={mft}>
<div
:if={get_member_count(mft, @member_counts) > 0}
class="tooltip tooltip-left"
data-tip={
gettext("Cannot delete - %{count} member(s) assigned",
count: get_member_count(mft, @member_counts)
)
}
>
<button
phx-click="delete"
phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-xs text-error opacity-50 cursor-not-allowed"
aria-label={
gettext("Cannot delete - %{count} member(s) assigned",
count: get_member_count(mft, @member_counts)
)
}
disabled={true}
>
<.icon name="hero-trash" class="size-4" />
</button>
</div>
<button
:if={get_member_count(mft, @member_counts) == 0}
phx-click="delete"
phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-xs text-error"
aria-label={gettext("Delete Membership Fee Type")}
>
<.icon name="hero-trash" class="size-4" />
</button>
</:action>
</.table>
<details class="mt-6 card bg-base-200">
<summary class="card-body cursor-pointer list-none card-title">
<.icon name="hero-information-circle" class="size-5" />
{gettext("About Membership Fee Types")}
</summary>
<div class="card-body pt-0 prose prose-sm max-w-none">
<p>
{gettext(
"Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
)}
</p>
<ul>
<li>
<strong>{gettext("Name & Amount")}</strong>
- {gettext("Can be changed at any time. Amount changes affect future periods only.")}
</li>
<li>
<strong>{gettext("Interval")}</strong>
- {gettext(
"Fixed after creation. Members can only switch between types with the same interval."
)}
</li>
<li>
<strong>{gettext("Deletion")}</strong>
- {gettext("Only possible if no members are assigned to this type.")}
</li>
</ul>
</div>
</details>
</div>
</Layouts.app>
"""
end
@ -286,6 +456,32 @@ defmodule MvWeb.MembershipFeeSettingsLive do
defp format_interval(:half_yearly), do: gettext("Half-yearly")
defp format_interval(:yearly), do: gettext("Yearly")
defp load_member_counts(fee_types, actor) do
fee_type_ids = Enum.map(fee_types, & &1.id)
members =
Member
|> Ash.Query.filter(membership_fee_type_id in ^fee_type_ids)
|> Ash.Query.select([:membership_fee_type_id])
|> Ash.read!(domain: Membership, actor: actor)
members
|> Enum.group_by(& &1.membership_fee_type_id)
|> Enum.map(fn {fee_type_id, members_list} -> {fee_type_id, length(members_list)} end)
|> Map.new()
end
defp get_member_count(fee_type, member_counts) do
Map.get(member_counts, fee_type.id, 0)
end
defp format_error(%Ash.Error.Invalid{} = error) do
Enum.map_join(error.errors, ", ", fn e -> e.message end)
end
defp format_error(error) when is_binary(error), do: error
defp format_error(_error), do: gettext("An error occurred")
defp assign_form(%{assigns: %{settings: settings}} = socket) do
form =
AshPhoenix.Form.for_update(

View file

@ -384,7 +384,8 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
defp format_interval_value(value), do: to_string(value)
@spec return_path(String.t(), MembershipFeeType.t() | nil) :: String.t()
defp return_path("index", _membership_fee_type), do: ~p"/membership_fee_types"
defp return_path("index", _membership_fee_type), do: ~p"/membership_fee_settings"
defp return_path(_, _), do: ~p"/membership_fee_settings"
@spec get_affected_member_count(String.t(), Mv.Accounts.User.t() | nil) :: non_neg_integer()
# Checks if amount changed and updates socket assigns accordingly

View file

@ -47,7 +47,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
{gettext("Manage membership fee types for membership fees.")}
</:subtitle>
<:actions>
<.button variant="primary" navigate={~p"/membership_fee_types/new"}>
<.button variant="primary" navigate={~p"/membership_fee_settings/new_fee_type"}>
<.icon name="hero-plus" /> {gettext("New Membership Fee Type")}
</.button>
</:actions>
@ -79,7 +79,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
<:action :let={mft}>
<.link
navigate={~p"/membership_fee_types/#{mft.id}/edit"}
navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
class="btn btn-ghost btn-xs"
aria-label={gettext("Edit membership fee type")}
>

View file

@ -42,9 +42,8 @@ defmodule MvWeb.LiveUserAuth do
end
def on_mount(:live_no_user, _params, session, socket) do
# Set the locale for not logged in user to set the language in the Log-In Screen
# otherwise the locale is not taken for the Log-In Screen
locale = session["locale"] || "en"
# Set the locale for not logged in user (default from config, "de" in dev/prod).
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
Gettext.put_locale(MvWeb.Gettext, locale)
{:cont, assign(socket, :locale, locale)}

View file

@ -1,10 +1,11 @@
defmodule MvWeb.LocaleController do
use MvWeb, :controller
def set_locale(conn, %{"locale" => locale}) do
@supported_locales ["de", "en"]
def set_locale(conn, %{"locale" => locale}) when locale in @supported_locales do
conn
|> put_session(:locale, locale)
# Store locale in a cookie that persists beyond the session
|> put_resp_cookie("locale", locale,
max_age: 365 * 24 * 60 * 60,
same_site: "Lax",
@ -14,6 +15,8 @@ defmodule MvWeb.LocaleController do
|> redirect(to: get_referer(conn) || "/")
end
def set_locale(conn, _params), do: redirect(conn, to: get_referer(conn) || "/")
defp get_referer(conn) do
conn.req_headers
|> Enum.find(fn {k, _v} -> k == "referer" end)

View file

@ -8,30 +8,30 @@ defmodule MvWeb.PagePaths do
# Sidebar top-level menu paths
@members "/members"
@membership_fee_types "/membership_fee_types"
@statistics "/statistics"
# Administration submenu paths (all must match router)
@users "/users"
@groups "/groups"
@admin_roles "/admin/roles"
@admin_datafields "/admin/datafields"
@membership_fee_settings "/membership_fee_settings"
@admin_import "/admin/import"
@settings "/settings"
@admin_page_paths [
@users,
@groups,
@admin_roles,
@admin_datafields,
@membership_fee_settings,
@admin_import,
@settings
]
@doc "Path for Members index (sidebar and page permission check)."
def members, do: @members
@doc "Path for Membership Fee Types index (sidebar and page permission check)."
def membership_fee_types, do: @membership_fee_types
@doc "Path for Statistics page (sidebar and page permission check)."
def statistics, do: @statistics
@ -41,6 +41,8 @@ defmodule MvWeb.PagePaths do
def users, do: @users
def groups, do: @groups
def admin_roles, do: @admin_roles
def admin_datafields, do: @admin_datafields
def membership_fee_settings, do: @membership_fee_settings
def admin_import, do: @admin_import
def settings, do: @settings
end

View file

@ -68,16 +68,13 @@ defmodule MvWeb.Router do
live "/settings", GlobalSettingsLive
# Membership Fee Settings
# Membership Fee Settings (includes fee types list; new/edit under sub-routes)
live "/membership_fee_settings", MembershipFeeSettingsLive
# Membership Fee Types Management
live "/membership_fee_types", MembershipFeeTypeLive.Index, :index
live "/membership_fee_settings/new_fee_type", MembershipFeeTypeLive.Form, :new
live "/membership_fee_settings/:id/edit_fee_type", MembershipFeeTypeLive.Form, :edit
# Statistics
live "/statistics", StatisticsLive, :index
live "/membership_fee_types/new", MembershipFeeTypeLive.Form, :new
live "/membership_fee_types/:id/edit", MembershipFeeTypeLive.Form, :edit
# Groups Management
live "/groups", GroupLive.Index, :index
@ -91,6 +88,9 @@ defmodule MvWeb.Router do
live "/admin/roles/:id", RoleLive.Show, :show
live "/admin/roles/:id/edit", RoleLive.Form, :edit
# Datafields (member fields + custom fields)
live "/admin/datafields", DatafieldsLive
# Import (Admin only)
live "/admin/import", ImportLive
@ -112,7 +112,8 @@ defmodule MvWeb.Router do
auth_routes_prefix: "/auth",
on_mount: [{MvWeb.LiveUserAuth, :live_no_user}],
overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.DaisyUI],
gettext_backend: {MvWeb.Gettext, "auth"}
gettext_backend: {MvWeb.Gettext, "auth"},
live_view: MvWeb.SignInLive
# Remove this if you do not want to use the reset password feature
reset_route auth_routes_prefix: "/auth",
@ -212,8 +213,8 @@ defmodule MvWeb.Router do
end)
end
# Our supported languages for now are german and english, english as fallback language
# Our supported languages: German and English; default German.
defp supported_locale?(locale), do: locale in ["en", "de"]
defp fallback_locale(nil), do: "en"
defp fallback_locale(nil), do: Application.get_env(:mv, :default_locale, "de")
defp fallback_locale(locale), do: locale
end

View file

@ -27,8 +27,11 @@ defmodule MvWeb.Translations.MemberFields do
def label(:street), do: gettext("Street")
def label(:house_number), do: gettext("House Number")
def label(:postal_code), do: gettext("Postal Code")
def label(:country), do: gettext("Country")
def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date")
def label(:membership_fee_status), do: gettext("Membership Fee Status")
def label(:membership_fee_type), do: gettext("Fee Type")
def label(:groups), do: gettext("Groups")
# Fallback for unknown fields
def label(field) do

View file

@ -59,6 +59,12 @@ msgstr ""
msgid "Sign in"
msgstr ""
## Dynamic string from ash_authentication_phoenix OAuth2 component (strategy_name = "Oidc").
## Not auto-extractable because the msgid is constructed at runtime via string interpolation.
## Generated by Phoenix.Naming.humanize(:oidc) = "Oidc"
msgid "Sign in with Oidc"
msgstr ""
msgid "Signing in ..."
msgstr ""
@ -131,11 +137,18 @@ msgid "This OIDC account is already linked to another user. Please contact suppo
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "Language selection"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr ""
#: lib/mv_web/auth_overrides.ex
#, elixir-autogen, elixir-format
msgid "or"
msgstr ""

View file

@ -58,6 +58,9 @@ msgstr "Neues Passwort setzen"
msgid "Sign in"
msgstr "Anmelden"
msgid "Sign in with Oidc"
msgstr "Single Sign On"
msgid "Signing in ..."
msgstr "Anmelden..."
@ -130,11 +133,18 @@ msgid "This OIDC account is already linked to another user. Please contact suppo
msgstr "Dieses OIDC-Konto ist bereits mit einem anderen Benutzer verknüpft. Bitte kontaktiere den Support."
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "Language selection"
msgstr "Sprachauswahl"
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr "Sprache auswählen"
#: lib/mv_web/auth_overrides.ex
#, elixir-autogen, elixir-format
msgid "or"
msgstr "oder"

View file

@ -18,6 +18,7 @@ msgid "Actions"
msgstr "Aktionen"
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex
@ -27,6 +28,7 @@ msgid "Are you sure?"
msgstr "Bist du sicher?"
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format
msgid "Attempting to reconnect"
msgstr "Verbindung wird wiederhergestellt"
@ -115,11 +117,13 @@ msgid "Show"
msgstr "Anzeigen"
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format
msgid "Something went wrong!"
msgstr "Etwas ist schiefgelaufen!"
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format
msgid "We can't find the internet"
msgstr "Keine Internetverbindung gefunden"
@ -197,6 +201,7 @@ msgstr "Straße"
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "No"
@ -283,8 +288,6 @@ msgstr "Abbrechen"
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex
@ -320,6 +323,7 @@ msgstr "Benutzer*innen auflisten"
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
@ -333,6 +337,7 @@ msgstr "Mitglieder"
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/form.ex
@ -380,7 +385,6 @@ msgstr "Alle Mitglieder auswählen"
msgid "Select member"
msgstr "Mitglied auswählen"
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Settings"
@ -582,6 +586,16 @@ msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte verifiziere dein Pa
msgid "Unable to authenticate with OIDC. Please try again."
msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuche es erneut."
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "The authentication server is currently unavailable. Please try again later."
msgstr "Der Authentifizierungsserver ist derzeit nicht erreichbar. Bitte versuche es später erneut."
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Authentication configuration error. Please contact the administrator."
msgstr "Authentifizierungskonfigurationsfehler. Bitte kontaktiere den Administrator."
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Unable to sign in. Please try again."
@ -830,6 +844,7 @@ msgid "Create Member"
msgstr "Mitglied erstellen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
@ -841,11 +856,13 @@ msgstr "Betrag"
msgid "Back to Settings"
msgstr "Zurück zu den Einstellungen"
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Can be changed at any time. Amount changes affect future periods only."
msgstr "Kann jederzeit geändert werden. Änderungen des Betrags betreffen nur zukünftige Zyklen."
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Deletion"
@ -856,6 +873,7 @@ msgstr "Löschen"
msgid "Examples"
msgstr "Beispiele"
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Fixed after creation. Members can only switch between types with the same interval."
@ -874,6 +892,7 @@ msgid "Half-yearly"
msgstr "Halbjährlich"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
@ -912,11 +931,13 @@ msgstr "Mitglied zahlt ab dem nächsten vollständigen Jahr"
msgid "Monthly"
msgstr "Monatlich"
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Name & Amount"
msgstr "Name & Betrag"
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Only possible if no members are assigned to this type."
@ -990,7 +1011,7 @@ msgstr "Alle auswählen"
msgid "Select none"
msgstr "Keine auswählen"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Slug does not match. Deletion cancelled."
msgstr "Eingegebener Text war nicht korrekt. Vorgang abgebrochen."
@ -1032,11 +1053,6 @@ msgstr "Textfeld"
msgid "Yes/No-Selection"
msgstr "Ja/Nein-Auswahl"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Memberdata"
msgstr "Mitgliederdaten"
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#, elixir-autogen, elixir-format
@ -1048,7 +1064,7 @@ msgstr "Optional"
msgid "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview."
msgstr "Diese Datenfelder sind für MILA notwendig um Mitglieder zu identifizieren und zukünftig Beitragszahlungen zu berechnen. Aus diesem Grund können sie nicht gelöscht, aber in der Übersicht ausgeblendet werden."
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Member field %{action} successfully"
msgstr "Mitgliedsfeld wurde erfolgreich %{action}"
@ -1058,6 +1074,7 @@ msgstr "Mitgliedsfeld wurde erfolgreich %{action}"
msgid "A cycle for this period already exists"
msgstr "Ein Zyklus für diesen Zeitraum existiert bereits"
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "About Membership Fee Types"
@ -1074,6 +1091,7 @@ msgid "Already paid cycles will remain with the old amount."
msgstr "Bereits bezahlte Zyklen bleiben mit dem alten Betrag."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/helpers.ex
#, elixir-autogen, elixir-format
@ -1085,6 +1103,7 @@ msgstr "Ein Fehler ist aufgetreten"
msgid "Are you sure you want to delete this cycle?"
msgstr "Möchtest du diesen Zyklus wirklich löschen?"
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Cannot delete - %{count} member(s) assigned"
@ -1105,11 +1124,6 @@ msgstr "Die Änderung des Betrags betrifft %{count} Mitglied(er)."
msgid "Click to edit amount"
msgstr "Klicke, um den Betrag zu bearbeiten"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Configure global settings for membership fees."
msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren."
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Confirm Change"
@ -1220,6 +1234,7 @@ msgstr "Feld bearbeiten: %{field}"
msgid "Edit Membership Fee Type"
msgstr "Mitgliedsbeitragsart bearbeiten"
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Edit membership fee type"
@ -1318,6 +1333,7 @@ msgstr "Mitgliedsbeitragsstatus"
msgid "Membership Fee Type"
msgstr "Mitgliedsbeitragsart"
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Membership Fee Types"
@ -1334,6 +1350,7 @@ msgstr "Mitgliedsbeiträge"
msgid "Membership fee start"
msgstr "Beitragsbeginn"
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Membership fee type deleted"
@ -1354,6 +1371,7 @@ msgstr "Mitgliedsbeitragsart erfolgreich gespeichert"
msgid "Membership fee type updated. Cycles regenerated."
msgstr "Mitgliedsbeitragsart aktualisiert. Zyklen regeneriert."
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
@ -1364,6 +1382,7 @@ msgstr "Mitgliedsbeitragsarten definieren verschiedene Mitgliedsbeitragsstruktur
msgid "Monthly Interval - Joining Cycle Included"
msgstr "Monatliches Intervall Beitrittszeitraum einbezogen"
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
@ -1538,6 +1557,7 @@ msgstr "Jährliches Intervall Beitrittszeitraum einbezogen"
msgid "You are about to delete all %{count} cycles for this member."
msgstr "Du bist dabei alle %{count} Zyklen für dieses Mitglied zu löschen."
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete Membership Fee Type"
@ -1559,12 +1579,12 @@ msgstr "Spalten ein-/ausblenden"
msgid "Back to settings"
msgstr "Zurück zu den Einstellungen"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Data field %{action} successfully"
msgstr "Datenfeld erfolgreich %{action}"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Data field deleted successfully"
msgstr "Datenfeld erfolgreich gelöscht"
@ -1579,7 +1599,7 @@ msgstr "Datenfeld löschen"
msgid "Edit Data Field"
msgstr "Datenfeld bearbeiten"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Failed to delete data field: %{error}"
msgstr "Konnte Datenfeld nicht löschen: %{error}"
@ -1811,6 +1831,7 @@ msgstr "Zyklus löschen"
msgid "The cycle period will be calculated based on this date and the interval."
msgstr "Der Zyklus wird basierend auf diesem Datum und dem Intervall berechnet."
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Membership fee type not found"
@ -1831,6 +1852,7 @@ msgstr "Benutzer*in erfolgreich gelöscht"
msgid "User not found"
msgstr "Benutzer*in nicht gefunden"
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this membership fee type"
@ -1841,6 +1863,7 @@ msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen"
msgid "You do not have permission to access this user"
msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen"
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this membership fee type"
@ -1912,16 +1935,6 @@ msgstr "E-Mail ist erforderlich."
msgid "Roles"
msgstr "Rollen"
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Fee Settings"
msgstr "Beitragseinstellungen"
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Fee Types"
msgstr "Beitragstypen"
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Administration"
@ -2037,11 +2050,6 @@ msgstr "Fehlgeschlagen: %{count} Zeile(n)"
msgid "German Template"
msgstr "Deutsche Vorlage"
#: lib/mv_web/live/import_live.ex
#, elixir-autogen, elixir-format
msgid "Import Members (CSV)"
msgstr "Mitglieder importieren (CSV)"
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Import Results"
@ -2205,6 +2213,7 @@ msgstr "Gruppe erfolgreich gespeichert."
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Groups"
msgstr "Gruppen"
@ -2259,16 +2268,11 @@ msgstr "Dieser Benutzer kann nicht angezeigt werden."
msgid "Not authorized."
msgstr "Nicht berechtigt."
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Could not load data fields. Please check your permissions."
msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfe deine Berechtigungen."
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Could not load member search. Please try again."
msgstr "Mitgliedersuche konnte nicht geladen werden. Bitte versuchen Sie es erneut."
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Add Member"
@ -2374,11 +2378,6 @@ msgstr "Du hast keine Berechtigung, auf diese Seite zuzugreifen."
msgid "Manage Member Data"
msgstr "Mitgliederdaten verwalten"
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV Datei. Datenfelder müssen in Mila bereits angelegt sein, damit sie importiert werden können. sie müssen in der Liste der Mitgliederdaten als Datenfeld enthalten sein (z.B. E-Mail). Spalten mit unbekannten Spaltenüberschriften werden mit einer Warnung ignoriert."
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export members to CSV"
@ -2415,6 +2414,7 @@ msgstr "Beitragsart auswählen"
msgid "Linked"
msgstr "Verknüpft"
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
@ -2566,7 +2566,7 @@ msgstr "Erstellt am:"
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export"
msgstr "Nach CSV exportieren"
msgstr "Export"
#: lib/mv_web/controllers/member_pdf_export_controller.ex
#, elixir-autogen, elixir-format
@ -2609,17 +2609,514 @@ msgstr "Import"
msgid "Value type cannot be changed after creation"
msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden."
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Export Members (CSV)"
#~ msgstr "Mitglieder exportieren (CSV)"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Country"
msgstr "Land"
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Export functionality will be available in a future release."
#~ msgstr "Export-Funktionalität ist im nächsten release verfügbar."
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Could not load member list. Please try again."
msgstr "Mitgliederliste konnte nicht geladen werden. Bitte versuche es erneut."
#~ #: lib/mv_web/live/import_export_live.ex
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "API Key"
msgstr "API-Schlüssel"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "API URL"
msgstr "API-URL"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Club ID"
msgstr "Vereins-ID"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_API_KEY"
msgstr "Aus VEREINFACHT_API_KEY"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_API_URL"
msgstr "Aus VEREINFACHT_API_URL"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_CLUB_ID"
msgstr "Aus VEREINFACHT_CLUB_ID"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Save Vereinfacht Settings"
msgstr "Vereinfacht-Einstellungen speichern"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Sync all members without Vereinfacht contact"
msgstr "Alle Mitglieder ohne Vereinfacht-Kontakt synchronisieren"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Synced %{count} member(s) to Vereinfacht."
msgstr "%{count} Mitglied(er) mit Vereinfacht synchronisiert."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Syncing..."
msgstr "Synchronisiere..."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht Integration"
msgstr "Vereinfacht-Integration"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID."
msgstr "Vereinfacht ist nicht konfiguriert. Bitte setze API-URL, API-Schlüssel und Vereins-ID."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Test Integration"
msgstr "Integration testen"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Testing..."
msgstr "Wird getestet..."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection successful. API URL, API Key and Club ID are valid."
msgstr "Verbindung erfolgreich. API-URL, API-Schlüssel und Vereins-ID sind korrekt."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Not configured. Please set API URL, API Key and Club ID."
msgstr "Nicht konfiguriert. Bitte API-URL, API-Schlüssel und Vereins-ID setzen."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed (HTTP %{status}):"
msgstr "Verbindung fehlgeschlagen (HTTP %{status}):"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed (HTTP 401): API key is invalid or missing."
msgstr "Verbindung fehlgeschlagen (HTTP 401): API-Schlüssel ist ungültig oder fehlt."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed (HTTP 403): Access denied. Please check the Club ID and API key permissions."
msgstr "Verbindung fehlgeschlagen (HTTP 403): Zugriff verweigert. Bitte Vereins-ID und Berechtigungen des API-Schlüssels prüfen."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed (HTTP 404): API endpoint not found. Please check the API URL (e.g. correct version path)."
msgstr "Verbindung fehlgeschlagen (HTTP 404): API-Endpunkt nicht gefunden. Bitte die API-URL prüfen (z. B. korrekter Versionspfad)."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed. The URL does not point to a Vereinfacht API (received HTML instead of JSON)."
msgstr "Verbindung fehlgeschlagen. Die URL zeigt nicht auf eine Vereinfacht-API (HTML statt JSON erhalten)."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed. Could not reach the API (network error or wrong URL)."
msgstr "Verbindung fehlgeschlagen. API nicht erreichbar (Netzwerkfehler oder falsche URL)."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed. Unknown error."
msgstr "Verbindung fehlgeschlagen. Unbekannter Fehler."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "View contact in Vereinfacht"
msgstr "Kontakt in Vereinfacht anzeigen"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "%{count} failed"
msgstr "%{count} fehlgeschlagen"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "%{count} synced"
msgstr "%{count} synchronisiert"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Failed members:"
msgstr "Fehlgeschlagene Mitglieder:"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Last sync result:"
msgstr "Letztes Sync-Ergebnis:"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Synced %{count} member(s). %{error_count} failed."
msgstr "%{count} Mitglied(er) synchronisiert. %{error_count} Fehler."
# Vereinfacht API error messages (translated for UI)
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht: %{detail}"
msgstr "Vereinfacht: %{detail}"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No Vereinfacht contact exists for this member."
msgstr "Für dieses Mitglied existiert kein Vereinfacht-Kontakt."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
msgstr "Synchronisiere dieses Mitglied unter Einstellungen (Bereich Vereinfacht) oder speichere das Mitglied erneut, um den Kontakt anzulegen."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "(set)"
msgstr "(gesetzt)"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Leave blank to keep current"
msgstr "Leer lassen, um den aktuellen Wert beizubehalten"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Some values are set via environment variables. Those fields are read-only."
msgstr "Einige Werte werden über Umgebungsvariablen gesetzt. Diese Felder sind schreibgeschützt."
# Vereinfacht API validation messages (looked up at runtime via dgettext)
msgid "The address field is required."
msgstr "Das Adressfeld ist erforderlich."
msgid "The city field is required."
msgstr "Das Stadtfeld ist erforderlich."
msgid "The email field is required."
msgstr "Das E-Mail-Feld ist erforderlich."
msgid "The first name field is required."
msgstr "Das Vornamenfeld ist erforderlich."
msgid "The last name field is required."
msgstr "Das Nachnamenfeld ist erforderlich."
msgid "The zip code field is required."
msgstr "Das Postleitzahlenfeld ist erforderlich."
msgid "Too Many Attempts."
msgstr "Zu viele Versuche."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "App URL (contact view link)"
msgstr "App-URL (Link zur Kontaktansicht)"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "From VEREINFACHT_APP_URL"
msgstr "Aus VEREINFACHT_APP_URL"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Error loading receipts: %{reason}"
msgstr "Belege konnten nicht geladen werden: %{reason}"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No receipts"
msgstr "Keine Belege"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Show bookings/receipts from Vereinfacht"
msgstr "Buchungen/Belege aus Vereinfacht anzeigen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht receipts"
msgstr "Vereinfacht-Belege"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Cancelled"
msgstr "Storniert"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Credit"
msgstr "Gutschrift"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Credit note"
msgstr "Gutschrift"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Draft"
msgstr "Entwurf"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Invoice"
msgstr "Rechnung"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Open"
msgstr "Offen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Receipt"
msgstr "Beleg"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Apr."
msgstr "Apr."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Aug."
msgstr "Aug."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Completed"
msgstr "Abgeschlossen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Dec."
msgstr "Dez."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Expense"
msgstr "Ausgabe"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Feb."
msgstr "Feb."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Income"
msgstr "Einnahme"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Incompleted"
msgstr "Unvollständig"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jan."
msgstr "Jan."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jul."
msgstr "Jul."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jun."
msgstr "Jun."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Mar."
msgstr "Mär."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "May"
msgstr "Mai"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Nov."
msgstr "Nov."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Oct."
msgstr "Okt."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Sep."
msgstr "Sep."
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Required for Vereinfacht integration and cannot be disabled."
msgstr "Für die Vereinfacht-Integration erforderlich und kann nicht deaktiviert werden."
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Fee Type"
msgstr "Beitragsart"
#: lib/mv_web/live/import_live.ex
#, elixir-autogen, elixir-format
msgid "Import members from CSV files."
msgstr "Miglieder aus CSV Dateien importieren."
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV Datei. Datenfelder müssen in Mila bereits angelegt sein, da unbekannte Spaltennamen ignoriert werden. Gruppen und der Beitragsstatus kann nicht importiert werden."
#: lib/mv_web/live/import_live.ex
#, elixir-autogen, elixir-format
msgid "Choose CSV file"
msgstr "CSV Datei auswählen"
#: lib/mv_web/live/import_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Import Members"
msgstr "Mitglieder importieren (CSV)"
#~ #: lib/mv_web/live/import_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Import members from CSV files or export member data."
#~ msgstr "Importiere Mitglieder aus CSV-Dateien oder exportiere Mitgliederdaten."
#~ msgid "Datei auswählen"
#~ msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Admin group name"
msgstr "Admin-Gruppenname"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Base URL"
msgstr "Basis-URL"
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Basic settings"
msgstr "Grundeinstellungen"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Client ID"
msgstr "Client-ID"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Client Secret"
msgstr "Client-Geheimnis"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Configure global settings and fee types for membership fees."
msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren."
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Configure member fields and custom data fields."
msgstr "Mitgliedsfelder und benutzerdefinierte Datenfelder konfigurieren."
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom fields"
msgstr "Benutzerdefinierte Felder"
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Datafields"
msgstr "Datenfelder"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From OIDC_ADMIN_GROUP_NAME"
msgstr "Aus OIDC_ADMIN_GROUP_NAME"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From OIDC_BASE_URL"
msgstr "Aus OIDC_BASE_URL"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From OIDC_CLIENT_ID"
msgstr "Aus OIDC_CLIENT_ID"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From OIDC_CLIENT_SECRET"
msgstr "Aus OIDC_CLIENT_SECRET"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From OIDC_GROUPS_CLAIM"
msgstr "Aus OIDC_GROUPS_CLAIM"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From OIDC_REDIRECT_URI"
msgstr "Aus OIDC_REDIRECT_URI"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Groups claim"
msgstr "Gruppenclaim"
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Member fields"
msgstr "Mitgliedsfelder"
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee settings"
msgstr "Beitragseinstellungen"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Redirect URI"
msgstr "Weiterleitungs-URI"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Save OIDC Settings"
msgstr "OIDC-Einstellungen speichern"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "e.g. admin"
msgstr "z. B. admin"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From OIDC_ONLY"
msgstr "Aus OIDC_ONLY"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Only OIDC sign-in (hide password login)"
msgstr "Nur OIDC-Anmeldung (Passwort-Login ausblenden)"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
msgstr "Wenn aktiviert und OIDC konfiguriert ist, zeigt die Anmeldeseite nur den Single-Sign-On-Button."

View file

@ -19,6 +19,7 @@ msgid "Actions"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex
@ -28,6 +29,7 @@ msgid "Are you sure?"
msgstr ""
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format
msgid "Attempting to reconnect"
msgstr ""
@ -116,11 +118,13 @@ msgid "Show"
msgstr ""
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format
msgid "Something went wrong!"
msgstr ""
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format
msgid "We can't find the internet"
msgstr ""
@ -198,6 +202,7 @@ msgstr ""
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "No"
@ -284,8 +289,6 @@ msgstr ""
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex
@ -321,6 +324,7 @@ msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
@ -334,6 +338,7 @@ msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/form.ex
@ -381,7 +386,6 @@ msgstr ""
msgid "Select member"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Settings"
@ -583,6 +587,16 @@ msgstr ""
msgid "Unable to authenticate with OIDC. Please try again."
msgstr ""
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "The authentication server is currently unavailable. Please try again later."
msgstr ""
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Authentication configuration error. Please contact the administrator."
msgstr ""
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Unable to sign in. Please try again."
@ -831,6 +845,7 @@ msgid "Create Member"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
@ -842,11 +857,13 @@ msgstr ""
msgid "Back to Settings"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Can be changed at any time. Amount changes affect future periods only."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Deletion"
@ -857,6 +874,7 @@ msgstr ""
msgid "Examples"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Fixed after creation. Members can only switch between types with the same interval."
@ -875,6 +893,7 @@ msgid "Half-yearly"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
@ -913,11 +932,13 @@ msgstr ""
msgid "Monthly"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Name & Amount"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Only possible if no members are assigned to this type."
@ -991,7 +1012,7 @@ msgstr ""
msgid "Select none"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Slug does not match. Deletion cancelled."
msgstr ""
@ -1033,11 +1054,6 @@ msgstr ""
msgid "Yes/No-Selection"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Memberdata"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#, elixir-autogen, elixir-format
@ -1049,7 +1065,7 @@ msgstr ""
msgid "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Member field %{action} successfully"
msgstr ""
@ -1059,6 +1075,7 @@ msgstr ""
msgid "A cycle for this period already exists"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "About Membership Fee Types"
@ -1075,6 +1092,7 @@ msgid "Already paid cycles will remain with the old amount."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/helpers.ex
#, elixir-autogen, elixir-format
@ -1086,6 +1104,7 @@ msgstr ""
msgid "Are you sure you want to delete this cycle?"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Cannot delete - %{count} member(s) assigned"
@ -1106,11 +1125,6 @@ msgstr ""
msgid "Click to edit amount"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Configure global settings for membership fees."
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Confirm Change"
@ -1221,6 +1235,7 @@ msgstr ""
msgid "Edit Membership Fee Type"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Edit membership fee type"
@ -1319,6 +1334,7 @@ msgstr ""
msgid "Membership Fee Type"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Membership Fee Types"
@ -1335,6 +1351,7 @@ msgstr ""
msgid "Membership fee start"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Membership fee type deleted"
@ -1355,6 +1372,7 @@ msgstr ""
msgid "Membership fee type updated. Cycles regenerated."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
@ -1365,6 +1383,7 @@ msgstr ""
msgid "Monthly Interval - Joining Cycle Included"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
@ -1539,6 +1558,7 @@ msgstr ""
msgid "You are about to delete all %{count} cycles for this member."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Delete Membership Fee Type"
@ -1560,12 +1580,12 @@ msgstr ""
msgid "Back to settings"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Data field %{action} successfully"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Data field deleted successfully"
msgstr ""
@ -1580,7 +1600,7 @@ msgstr ""
msgid "Edit Data Field"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to delete data field: %{error}"
msgstr ""
@ -1812,6 +1832,7 @@ msgstr ""
msgid "The cycle period will be calculated based on this date and the interval."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Membership fee type not found"
@ -1832,6 +1853,7 @@ msgstr ""
msgid "User not found"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to access this membership fee type"
@ -1842,6 +1864,7 @@ msgstr ""
msgid "You do not have permission to access this user"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this membership fee type"
@ -1913,16 +1936,6 @@ msgstr ""
msgid "Roles"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Fee Settings"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Fee Types"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Administration"
@ -2038,11 +2051,6 @@ msgstr ""
msgid "German Template"
msgstr ""
#: lib/mv_web/live/import_live.ex
#, elixir-autogen, elixir-format
msgid "Import Members (CSV)"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Import Results"
@ -2206,6 +2214,7 @@ msgstr ""
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Groups"
msgstr ""
@ -2260,16 +2269,11 @@ msgstr ""
msgid "Not authorized."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Could not load data fields. Please check your permissions."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Could not load member search. Please try again."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Add Member"
@ -2375,11 +2379,6 @@ msgstr ""
msgid "Manage Member Data"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
msgstr ""
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "Export members to CSV"
@ -2416,6 +2415,7 @@ msgstr ""
msgid "Linked"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
@ -2609,3 +2609,509 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Value type cannot be changed after creation"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Country"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Could not load member list. Please try again."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "API Key"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "API URL"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Club ID"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_API_KEY"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_API_URL"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_CLUB_ID"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Save Vereinfacht Settings"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Sync all members without Vereinfacht contact"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Synced %{count} member(s) to Vereinfacht."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Syncing..."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht Integration"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Test Integration"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Testing..."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection successful. API URL, API Key and Club ID are valid."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Not configured. Please set API URL, API Key and Club ID."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed (HTTP %{status}):"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed (HTTP 401): API key is invalid or missing."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed (HTTP 403): Access denied. Please check the Club ID and API key permissions."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed (HTTP 404): API endpoint not found. Please check the API URL (e.g. correct version path)."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed. The URL does not point to a Vereinfacht API (received HTML instead of JSON)."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed. Could not reach the API (network error or wrong URL)."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed. Unknown error."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "View contact in Vereinfacht"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "%{count} failed"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "%{count} synced"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed members:"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Last sync result:"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Synced %{count} member(s). %{error_count} failed."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht: %{detail}"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No Vereinfacht contact exists for this member."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "(set)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Leave blank to keep current"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Some values are set via environment variables. Those fields are read-only."
msgstr ""
# Vereinfacht API validation messages (looked up at runtime via dgettext)
msgid "The address field is required."
msgstr ""
msgid "The city field is required."
msgstr ""
msgid "The email field is required."
msgstr ""
msgid "The first name field is required."
msgstr ""
msgid "The last name field is required."
msgstr ""
msgid "The zip code field is required."
msgstr ""
msgid "Too Many Attempts."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "App URL (contact view link)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_APP_URL"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Error loading receipts: %{reason}"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No receipts"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Show bookings/receipts from Vereinfacht"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht receipts"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Cancelled"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Credit"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Credit note"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Draft"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Invoice"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Open"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Receipt"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Apr."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Aug."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Completed"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Dec."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Expense"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Feb."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Income"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Incompleted"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jan."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jul."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jun."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Mar."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "May"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Nov."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Oct."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Sep."
msgstr ""
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Required for Vereinfacht integration and cannot be disabled."
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Fee Type"
msgstr ""
#: lib/mv_web/live/import_live.ex
#, elixir-autogen, elixir-format
msgid "Import members from CSV files."
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
msgstr ""
#: lib/mv_web/live/import_live.ex
#, elixir-autogen, elixir-format
msgid "Choose CSV file"
msgstr ""
#: lib/mv_web/live/import_live.ex
#, elixir-autogen, elixir-format
msgid "Import Members"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Admin group name"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Base URL"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Basic settings"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Client ID"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Client Secret"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Configure global settings and fee types for membership fees."
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Configure member fields and custom data fields."
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Custom fields"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Datafields"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From OIDC_ADMIN_GROUP_NAME"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From OIDC_BASE_URL"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From OIDC_CLIENT_ID"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From OIDC_CLIENT_SECRET"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From OIDC_GROUPS_CLAIM"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From OIDC_REDIRECT_URI"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Groups claim"
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Member fields"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Membership fee settings"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Redirect URI"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Save OIDC Settings"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "e.g. admin"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From OIDC_ONLY"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Only OIDC sign-in (hide password login)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
msgstr ""

View file

@ -55,6 +55,9 @@ msgstr ""
msgid "Sign in"
msgstr ""
msgid "Sign in with Oidc"
msgstr "Single Sign On"
msgid "Signing in ..."
msgstr ""
@ -127,11 +130,18 @@ msgid "This OIDC account is already linked to another user. Please contact suppo
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "Language selection"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#: lib/mv_web/live/auth/sign_in_live.ex
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr ""
#: lib/mv_web/auth_overrides.ex
#, elixir-autogen, elixir-format
msgid "or"
msgstr "or"

View file

@ -19,6 +19,7 @@ msgid "Actions"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex
@ -28,6 +29,7 @@ msgid "Are you sure?"
msgstr ""
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format
msgid "Attempting to reconnect"
msgstr ""
@ -116,11 +118,13 @@ msgid "Show"
msgstr ""
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format
msgid "Something went wrong!"
msgstr ""
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format
msgid "We can't find the internet"
msgstr ""
@ -198,6 +202,7 @@ msgstr ""
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "No"
@ -284,8 +289,6 @@ msgstr ""
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex
@ -321,6 +324,7 @@ msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
@ -334,6 +338,7 @@ msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/form.ex
@ -381,7 +386,6 @@ msgstr ""
msgid "Select member"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Settings"
@ -583,6 +587,16 @@ msgstr ""
msgid "Unable to authenticate with OIDC. Please try again."
msgstr ""
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "The authentication server is currently unavailable. Please try again later."
msgstr ""
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Authentication configuration error. Please contact the administrator."
msgstr ""
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Unable to sign in. Please try again."
@ -831,6 +845,7 @@ msgid "Create Member"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
@ -842,11 +857,13 @@ msgstr ""
msgid "Back to Settings"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Can be changed at any time. Amount changes affect future periods only."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Deletion"
@ -857,6 +874,7 @@ msgstr ""
msgid "Examples"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Fixed after creation. Members can only switch between types with the same interval."
@ -875,6 +893,7 @@ msgid "Half-yearly"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
@ -913,11 +932,13 @@ msgstr ""
msgid "Monthly"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Name & Amount"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "Only possible if no members are assigned to this type."
@ -991,7 +1012,7 @@ msgstr ""
msgid "Select none"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Slug does not match. Deletion cancelled."
msgstr ""
@ -1033,11 +1054,6 @@ msgstr ""
msgid "Yes/No-Selection"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Memberdata"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
@ -1049,7 +1065,7 @@ msgstr ""
msgid "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Member field %{action} successfully"
msgstr ""
@ -1059,6 +1075,7 @@ msgstr ""
msgid "A cycle for this period already exists"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "About Membership Fee Types"
@ -1075,6 +1092,7 @@ msgid "Already paid cycles will remain with the old amount."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/helpers.ex
#, elixir-autogen, elixir-format
@ -1086,6 +1104,7 @@ msgstr ""
msgid "Are you sure you want to delete this cycle?"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Cannot delete - %{count} member(s) assigned"
@ -1106,11 +1125,6 @@ msgstr ""
msgid "Click to edit amount"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Configure global settings for membership fees."
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Confirm Change"
@ -1221,6 +1235,7 @@ msgstr ""
msgid "Edit Membership Fee Type"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Edit membership fee type"
@ -1319,6 +1334,7 @@ msgstr ""
msgid "Membership Fee Type"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership Fee Types"
@ -1335,6 +1351,7 @@ msgstr ""
msgid "Membership fee start"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee type deleted"
@ -1355,6 +1372,7 @@ msgstr ""
msgid "Membership fee type updated. Cycles regenerated."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
@ -1365,6 +1383,7 @@ msgstr ""
msgid "Monthly Interval - Joining Cycle Included"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
@ -1539,6 +1558,7 @@ msgstr ""
msgid "You are about to delete all %{count} cycles for this member."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete Membership Fee Type"
@ -1560,12 +1580,12 @@ msgstr ""
msgid "Back to settings"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Data field %{action} successfully"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Data field deleted successfully"
msgstr ""
@ -1580,7 +1600,7 @@ msgstr ""
msgid "Edit Data Field"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Failed to delete data field: %{error}"
msgstr ""
@ -1812,6 +1832,7 @@ msgstr ""
msgid "The cycle period will be calculated based on this date and the interval."
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee type not found"
@ -1832,6 +1853,7 @@ msgstr ""
msgid "User not found"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to access this membership fee type"
@ -1842,6 +1864,7 @@ msgstr ""
msgid "You do not have permission to access this user"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to delete this membership fee type"
@ -1913,16 +1936,6 @@ msgstr ""
msgid "Roles"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Fee Settings"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Fee Types"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Administration"
@ -2038,11 +2051,6 @@ msgstr ""
msgid "German Template"
msgstr ""
#: lib/mv_web/live/import_live.ex
#, elixir-autogen, elixir-format
msgid "Import Members (CSV)"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Import Results"
@ -2206,6 +2214,7 @@ msgstr ""
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Groups"
msgstr ""
@ -2260,16 +2269,11 @@ msgstr ""
msgid "Not authorized."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Could not load data fields. Please check your permissions."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Could not load member search. Please try again."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Add Member"
@ -2375,11 +2379,6 @@ msgstr ""
msgid "Manage Member Data"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
msgstr ""
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export members to CSV"
@ -2416,6 +2415,7 @@ msgstr ""
msgid "Linked"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
@ -2610,17 +2610,508 @@ msgstr ""
msgid "Value type cannot be changed after creation"
msgstr ""
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Export Members (CSV)"
#~ msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Country"
msgstr ""
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Export functionality will be available in a future release."
#~ msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Could not load member list. Please try again."
msgstr ""
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Import members from CSV files or export member data."
#~ msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "API Key"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "API URL"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Club ID"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_API_KEY"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_API_URL"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_CLUB_ID"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Save Vereinfacht Settings"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Sync all members without Vereinfacht contact"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Synced %{count} member(s) to Vereinfacht."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Syncing..."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht Integration"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Test Integration"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Testing..."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection successful. API URL, API Key and Club ID are valid."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Not configured. Please set API URL, API Key and Club ID."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed (HTTP %{status}):"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed (HTTP 401): API key is invalid or missing."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed (HTTP 403): Access denied. Please check the Club ID and API key permissions."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed (HTTP 404): API endpoint not found. Please check the API URL (e.g. correct version path)."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed. The URL does not point to a Vereinfacht API (received HTML instead of JSON)."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed. Could not reach the API (network error or wrong URL)."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Connection failed. Unknown error."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "View contact in Vereinfacht"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "%{count} failed"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "%{count} synced"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Failed members:"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Last sync result:"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Synced %{count} member(s). %{error_count} failed."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Vereinfacht: %{detail}"
msgstr "Vereinfacht: %{detail}"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No Vereinfacht contact exists for this member."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
msgstr "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "(set)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Leave blank to keep current"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Some values are set via environment variables. Those fields are read-only."
msgstr ""
# Vereinfacht API validation messages (looked up at runtime via dgettext)
msgid "The address field is required."
msgstr ""
msgid "The city field is required."
msgstr ""
msgid "The email field is required."
msgstr ""
msgid "The first name field is required."
msgstr ""
msgid "The last name field is required."
msgstr ""
msgid "The zip code field is required."
msgstr ""
msgid "Too Many Attempts."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "App URL (contact view link)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "From VEREINFACHT_APP_URL"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Error loading receipts: %{reason}"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No receipts"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Show bookings/receipts from Vereinfacht"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Vereinfacht receipts"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Cancelled"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Credit"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Credit note"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Draft"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Invoice"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Open"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Receipt"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Apr."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Aug."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Completed"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Dec."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Expense"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Feb."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Income"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Incompleted"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jan."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jul."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jun."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Mar."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "May"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Nov."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Oct."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Sep."
msgstr ""
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Required for Vereinfacht integration and cannot be disabled."
msgstr "Required for Vereinfacht integration and cannot be disabled."
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Fee Type"
msgstr "Fee Type"
#: lib/mv_web/live/import_live.ex
#, elixir-autogen, elixir-format
msgid "Import members from CSV files."
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
msgstr ""
#: lib/mv_web/live/import_live.ex
#, elixir-autogen, elixir-format
msgid "Choose CSV file"
msgstr ""
#: lib/mv_web/live/import_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Import Members"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Admin group name"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Base URL"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Basic settings"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Client ID"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Client Secret"
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Configure global settings and fee types for membership fees."
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Configure member fields and custom data fields."
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom fields"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Datafields"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From OIDC_ADMIN_GROUP_NAME"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From OIDC_BASE_URL"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From OIDC_CLIENT_ID"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From OIDC_CLIENT_SECRET"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From OIDC_GROUPS_CLAIM"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From OIDC_REDIRECT_URI"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Groups claim"
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Member fields"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee settings"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Redirect URI"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save OIDC Settings"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "e.g. admin"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "From OIDC_ONLY"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Only OIDC sign-in (hide password login)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
msgstr ""

View file

@ -9,7 +9,13 @@
#set page(
paper: "a4",
flipped: true,
margin: (top: 1.2cm, bottom: 1.2cm, left: 1.0cm, right: 1.0cm)
margin: (top: 1.2cm, bottom: 1.2cm, left: 1.0cm, right: 1.0cm),
numbering: "1",
footer: context [
#set text(size: 8pt)
#set align(center)
#counter(page).display("1 / 1", both: true)
]
)
#set text(size: 9pt, hyphenate: true)
@ -58,7 +64,6 @@
#let start = fixed_count + chunk_index * max_dynamic_cols
#let page_cols = fixed_cols + dyn_cols_chunk
#let headers = page_cols.map(c => c.at("label", default: ""))
// widths: first two columns fixed (32mm, 42mm), rest distributed as 1fr
#let widths = (
@ -67,9 +72,9 @@
..((1fr,) * dyn_count)
)
#let header_cells = headers.map(h => text(weight: "bold", size: 9pt)[#h])
#let header_cells = page_cols.map(c => text(weight: "bold", size: 9pt)[#c.at("label", default: "")])
// Body cells (row-major), nur die Spalten dieses Chunks
// Body cells (row-major), only columns of this chunk
#let body_cells = (
rows
.map(row => row.slice(0, fixed_count) + row.slice(start, start + dyn_count))
@ -77,8 +82,27 @@
.flatten()
)
// Thinner grid for body; thicker vertical line between column 2 and 3; header and outer borders thick
#let thin_stroke = 0.3pt + black
#let thick_sep = 1.5pt + black
#let thick_stroke = 1pt + black
#let last_x = fixed_count + dyn_count - 1
#let last_y = rows.len()
#let stroke_fn = (x, y) => {
let top = if y == 0 or y == 1 { thick_stroke } else { thin_stroke }
let bottom = if y == 0 or y == last_y { thick_stroke } else { thin_stroke }
let left = if x == 0 { thick_stroke } else if x == 2 { thick_sep } else { thin_stroke }
let right = if x == last_x { thick_stroke } else { thin_stroke }
(top: top, bottom: bottom, left: left, right: right)
}
// Light gray background for first two columns (first_name, last_name)
#let fill_fn = (x, y) => if x < 2 { rgb("f2f2f2") } else { none }
#table(
columns: widths,
stroke: stroke_fn,
fill: fill_fn,
table.header(..header_cells),
..body_cells,
)

View file

@ -0,0 +1,21 @@
defmodule Mv.Repo.Migrations.AddVereinfachtContactIdToMembers do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:members) do
add :vereinfacht_contact_id, :text
end
end
def down do
alter table(:members) do
remove :vereinfacht_contact_id
end
end
end

View file

@ -0,0 +1,25 @@
defmodule Mv.Repo.Migrations.AddVereinfachtSettings do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:settings) do
add :vereinfacht_api_url, :text
add :vereinfacht_api_key, :text
add :vereinfacht_club_id, :text
end
end
def down do
alter table(:settings) do
remove :vereinfacht_club_id
remove :vereinfacht_api_key
remove :vereinfacht_api_url
end
end
end

View file

@ -0,0 +1,15 @@
defmodule Mv.Repo.Migrations.AddVereinfachtAppUrl do
use Ecto.Migration
def up do
alter table(:settings) do
add :vereinfacht_app_url, :text
end
end
def down do
alter table(:settings) do
remove :vereinfacht_app_url
end
end
end

View file

@ -0,0 +1,577 @@
defmodule Mv.Repo.Migrations.AddCountryToMembers do
@moduledoc """
Adds country as an optional member field and includes it in full-text search.
- Adds :country column to members table (text, nullable)
- Updates members_search_vector_trigger() to include country (weight C)
- Updates update_member_search_vector_from_custom_field_value() to include country
- Updates update_member_search_vector_from_member_groups() to include country
- Backfills existing members' search_vector with country
"""
use Ecto.Migration
def up do
alter table(:members) do
add :country, :text
end
# 1. Main trigger on members: add country to search_vector
execute("""
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
DECLARE
custom_values_text text;
groups_text text;
BEGIN
SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
INTO custom_values_text
FROM custom_field_values
WHERE member_id = NEW.id AND value IS NOT NULL;
SELECT string_agg(g.name, ' ')
INTO groups_text
FROM member_groups mg
JOIN groups g ON g.id = mg.group_id
WHERE mg.member_id = NEW.id;
NEW.search_vector :=
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.country, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
""")
# 2. Custom field trigger: include country in recomputed search_vector
execute("""
CREATE OR REPLACE FUNCTION update_member_search_vector_from_custom_field_value() RETURNS trigger AS $$
DECLARE
member_id_val uuid;
member_first_name text;
member_last_name text;
member_email text;
member_join_date date;
member_exit_date date;
member_notes text;
member_city text;
member_street text;
member_house_number text;
member_postal_code text;
member_country text;
custom_values_text text;
groups_text text;
old_value_text text;
new_value_text text;
BEGIN
member_id_val := COALESCE(NEW.member_id, OLD.member_id);
IF TG_OP = 'UPDATE' THEN
old_value_text := COALESCE(
NULLIF(OLD.value->>'_union_value', ''),
NULLIF(OLD.value->>'value', ''),
''
);
new_value_text := COALESCE(
NULLIF(NEW.value->>'_union_value', ''),
NULLIF(NEW.value->>'value', ''),
''
);
IF (old_value_text IS NOT DISTINCT FROM new_value_text) AND
(OLD.member_id IS NOT DISTINCT FROM NEW.member_id) AND
(OLD.custom_field_id IS NOT DISTINCT FROM NEW.custom_field_id) THEN
RETURN COALESCE(NEW, OLD);
END IF;
END IF;
SELECT
first_name,
last_name,
email,
join_date,
exit_date,
notes,
city,
street,
house_number,
postal_code,
country
INTO
member_first_name,
member_last_name,
member_email,
member_join_date,
member_exit_date,
member_notes,
member_city,
member_street,
member_house_number,
member_postal_code,
member_country
FROM members
WHERE id = member_id_val;
SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
INTO custom_values_text
FROM custom_field_values
WHERE member_id = member_id_val AND value IS NOT NULL;
SELECT string_agg(g.name, ' ')
INTO groups_text
FROM member_groups mg
JOIN groups g ON g.id = mg.group_id
WHERE mg.member_id = member_id_val;
UPDATE members
SET search_vector =
setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_country, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B')
WHERE id = member_id_val;
RETURN COALESCE(NEW, OLD);
END
$$ LANGUAGE plpgsql;
""")
# 3. Member groups trigger: include country when refreshing search_vector
execute("""
CREATE OR REPLACE FUNCTION update_member_search_vector_from_member_groups() RETURNS trigger AS $$
DECLARE
member_id_val uuid;
member_first_name text;
member_last_name text;
member_email text;
member_join_date date;
member_exit_date date;
member_notes text;
member_city text;
member_street text;
member_house_number text;
member_postal_code text;
member_country text;
custom_values_text text;
groups_text text;
BEGIN
FOR member_id_val IN
SELECT COALESCE(NEW.member_id, OLD.member_id)
UNION ALL
SELECT OLD.member_id
WHERE TG_OP = 'UPDATE' AND OLD.member_id IS DISTINCT FROM NEW.member_id
LOOP
SELECT
first_name,
last_name,
email,
join_date,
exit_date,
notes,
city,
street,
house_number,
postal_code,
country
INTO
member_first_name,
member_last_name,
member_email,
member_join_date,
member_exit_date,
member_notes,
member_city,
member_street,
member_house_number,
member_postal_code,
member_country
FROM members
WHERE id = member_id_val;
SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
INTO custom_values_text
FROM custom_field_values
WHERE member_id = member_id_val AND value IS NOT NULL;
SELECT string_agg(g.name, ' ')
INTO groups_text
FROM member_groups mg
JOIN groups g ON g.id = mg.group_id
WHERE mg.member_id = member_id_val;
UPDATE members
SET search_vector =
setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_country, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B')
WHERE id = member_id_val;
END LOOP;
RETURN COALESCE(NEW, OLD);
END
$$ LANGUAGE plpgsql;
""")
# 4. Backfill: update all members' search_vector to include country
execute("""
UPDATE members m
SET search_vector =
setweight(to_tsvector('simple', coalesce(m.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(m.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(m.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(m.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(m.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(m.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(m.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.country, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(
(SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
FROM custom_field_values
WHERE member_id = m.id AND value IS NOT NULL),
''
)), 'C') ||
setweight(to_tsvector('simple', coalesce(
(SELECT string_agg(g.name, ' ')
FROM member_groups mg
JOIN groups g ON g.id = mg.group_id
WHERE mg.member_id = m.id),
''
)), 'B')
""")
end
def down do
# Restore trigger functions without country (revert to previous version from AddGroupNamesToMemberSearchVector)
execute("""
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
DECLARE
custom_values_text text;
groups_text text;
BEGIN
SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
INTO custom_values_text
FROM custom_field_values
WHERE member_id = NEW.id AND value IS NOT NULL;
SELECT string_agg(g.name, ' ')
INTO groups_text
FROM member_groups mg
JOIN groups g ON g.id = mg.group_id
WHERE mg.member_id = NEW.id;
NEW.search_vector :=
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
""")
execute("""
CREATE OR REPLACE FUNCTION update_member_search_vector_from_custom_field_value() RETURNS trigger AS $$
DECLARE
member_id_val uuid;
member_first_name text;
member_last_name text;
member_email text;
member_join_date date;
member_exit_date date;
member_notes text;
member_city text;
member_street text;
member_house_number text;
member_postal_code text;
custom_values_text text;
groups_text text;
old_value_text text;
new_value_text text;
BEGIN
member_id_val := COALESCE(NEW.member_id, OLD.member_id);
IF TG_OP = 'UPDATE' THEN
old_value_text := COALESCE(
NULLIF(OLD.value->>'_union_value', ''),
NULLIF(OLD.value->>'value', ''),
''
);
new_value_text := COALESCE(
NULLIF(NEW.value->>'_union_value', ''),
NULLIF(NEW.value->>'value', ''),
''
);
IF (old_value_text IS NOT DISTINCT FROM new_value_text) AND
(OLD.member_id IS NOT DISTINCT FROM NEW.member_id) AND
(OLD.custom_field_id IS NOT DISTINCT FROM NEW.custom_field_id) THEN
RETURN COALESCE(NEW, OLD);
END IF;
END IF;
SELECT
first_name,
last_name,
email,
join_date,
exit_date,
notes,
city,
street,
house_number,
postal_code
INTO
member_first_name,
member_last_name,
member_email,
member_join_date,
member_exit_date,
member_notes,
member_city,
member_street,
member_house_number,
member_postal_code
FROM members
WHERE id = member_id_val;
SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
INTO custom_values_text
FROM custom_field_values
WHERE member_id = member_id_val AND value IS NOT NULL;
SELECT string_agg(g.name, ' ')
INTO groups_text
FROM member_groups mg
JOIN groups g ON g.id = mg.group_id
WHERE mg.member_id = member_id_val;
UPDATE members
SET search_vector =
setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B')
WHERE id = member_id_val;
RETURN COALESCE(NEW, OLD);
END
$$ LANGUAGE plpgsql;
""")
execute("""
CREATE OR REPLACE FUNCTION update_member_search_vector_from_member_groups() RETURNS trigger AS $$
DECLARE
member_id_val uuid;
member_first_name text;
member_last_name text;
member_email text;
member_join_date date;
member_exit_date date;
member_notes text;
member_city text;
member_street text;
member_house_number text;
member_postal_code text;
custom_values_text text;
groups_text text;
BEGIN
FOR member_id_val IN
SELECT COALESCE(NEW.member_id, OLD.member_id)
UNION ALL
SELECT OLD.member_id
WHERE TG_OP = 'UPDATE' AND OLD.member_id IS DISTINCT FROM NEW.member_id
LOOP
SELECT
first_name,
last_name,
email,
join_date,
exit_date,
notes,
city,
street,
house_number,
postal_code
INTO
member_first_name,
member_last_name,
member_email,
member_join_date,
member_exit_date,
member_notes,
member_city,
member_street,
member_house_number,
member_postal_code
FROM members
WHERE id = member_id_val;
SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
INTO custom_values_text
FROM custom_field_values
WHERE member_id = member_id_val AND value IS NOT NULL;
SELECT string_agg(g.name, ' ')
INTO groups_text
FROM member_groups mg
JOIN groups g ON g.id = mg.group_id
WHERE mg.member_id = member_id_val;
UPDATE members
SET search_vector =
setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B')
WHERE id = member_id_val;
END LOOP;
RETURN COALESCE(NEW, OLD);
END
$$ LANGUAGE plpgsql;
""")
# Backfill without country
execute("""
UPDATE members m
SET search_vector =
setweight(to_tsvector('simple', coalesce(m.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(m.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(m.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(m.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(m.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(m.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(m.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(m.postal_code::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(
(SELECT string_agg(
CASE
WHEN value ? '_union_value' THEN value->>'_union_value'
WHEN value ? 'value' THEN value->>'value'
ELSE ''
END,
' '
)
FROM custom_field_values
WHERE member_id = m.id AND value IS NOT NULL),
''
)), 'C') ||
setweight(to_tsvector('simple', coalesce(
(SELECT string_agg(g.name, ' ')
FROM member_groups mg
JOIN groups g ON g.id = mg.group_id
WHERE mg.member_id = m.id),
''
)), 'B')
""")
alter table(:members) do
remove :country
end
end
end

View file

@ -0,0 +1,21 @@
defmodule Mv.Repo.Migrations.AddMemberFieldRequiredToSettings do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:settings) do
add :member_field_required, :map
end
end
def down do
alter table(:settings) do
remove :member_field_required
end
end
end

View file

@ -0,0 +1,29 @@
defmodule Mv.Repo.Migrations.AddOidcToSettings do
@moduledoc """
Adds OIDC configuration columns to settings (ENV-overridable in UI).
"""
use Ecto.Migration
def up do
alter table(:settings) do
add :oidc_client_id, :string
add :oidc_base_url, :string
add :oidc_redirect_uri, :string
add :oidc_client_secret, :string
add :oidc_admin_group_name, :string
add :oidc_groups_claim, :string
end
end
def down do
alter table(:settings) do
remove :oidc_client_id
remove :oidc_base_url
remove :oidc_redirect_uri
remove :oidc_client_secret
remove :oidc_admin_group_name
remove :oidc_groups_claim
end
end
end

View file

@ -0,0 +1,20 @@
defmodule Mv.Repo.Migrations.AddOidcOnlyToSettings do
@moduledoc """
Adds oidc_only flag to settings. When true and OIDC is configured,
the sign-in page shows only OIDC (password login is hidden).
"""
use Ecto.Migration
def up do
alter table(:settings) do
add :oidc_only, :boolean, default: false, null: false
end
end
def down do
alter table(:settings) do
remove :oidc_only
end
end
end

View file

@ -3,10 +3,10 @@
# mix run priv/repo/seeds.exs
#
alias Mv.Membership
alias Mv.Accounts
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.MembershipFeeType
require Ash.Query
@ -328,6 +328,7 @@ member_attrs_list = [
city: "Berlin",
street: "Kastanienallee",
house_number: "8",
postal_code: "10435",
membership_fee_type_id: Enum.at(all_fee_types, 2).id,
cycle_status: :mixed
},
@ -338,7 +339,8 @@ member_attrs_list = [
join_date: ~D[2022-11-10],
city: "Berlin",
street: "Kastanienallee",
house_number: "8"
house_number: "8",
postal_code: "10435"
# No membership_fee_type_id - member without fee type
}
]
@ -579,6 +581,39 @@ Enum.with_index(linked_members)
end
end)
# Create example groups (idempotent: create only if name does not exist)
group_configs = [
%{name: "Vorstand", description: "Gremium Vorstand"},
%{name: "Trainer*innen", description: "Alle lizenzierten Trainer*innen"},
%{name: "Jugend", description: "Jugendbereich"},
%{name: "Newsletter", description: "Empfänger*innen Newsletter"}
]
existing_groups =
case Membership.list_groups(actor: admin_user_with_role) do
{:ok, list} -> list
{:error, _} -> []
end
existing_names_lower = MapSet.new(existing_groups, &String.downcase(&1.name))
seed_groups =
Enum.reduce(group_configs, %{}, fn config, acc ->
name = config.name
if MapSet.member?(existing_names_lower, String.downcase(name)) do
group = Enum.find(existing_groups, &(String.downcase(&1.name) == String.downcase(name)))
Map.put(acc, name, group)
else
group =
Membership.create_group!(%{name: name, description: config.description},
actor: admin_user_with_role
)
Map.put(acc, name, group)
end
end)
# Create sample custom field values for some members
all_members = Ash.read!(Membership.Member, actor: admin_user_with_role)
all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role)
@ -587,6 +622,35 @@ all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_rol
find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end
find_member = fn email -> Enum.find(all_members, &(&1.email == email)) end
# Assign seed members to groups (idempotent: duplicate create_member_group is skipped)
member_group_assignments = [
{"hans.mueller@example.de", ["Vorstand", "Newsletter"]},
{"greta.schmidt@example.de", ["Jugend", "Newsletter"]},
{"friedrich.wagner@example.de", ["Trainer*innen"]},
{"maria.weber@example.de", ["Newsletter"]},
{"thomas.klein@example.de", ["Newsletter"]}
]
Enum.each(member_group_assignments, fn {email, group_names} ->
member = find_member.(email)
if member do
Enum.each(group_names, fn group_name ->
group = seed_groups[group_name]
if group do
case Membership.create_member_group(
%{member_id: member.id, group_id: group.id},
actor: admin_user_with_role
) do
{:ok, _} -> :ok
{:error, _} -> :ok
end
end
end)
end
end)
# Add custom field values for Hans Müller
if hans = find_member.("hans.mueller@example.de") do
[
@ -731,6 +795,7 @@ IO.puts(
)
IO.puts(" - Sample members: Hans, Greta, Friedrich")
IO.puts(" - Groups: Vorstand, Trainer*innen, Jugend, Newsletter (with members assigned)")
IO.puts(
" - Additional users: hans.mueller@example.de, greta.schmidt@example.de, maria.weber@example.de, thomas.klein@example.de"

View file

@ -0,0 +1,234 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v7()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "first_name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "last_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "email",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "join_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "exit_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "notes",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "city",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "street",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "house_number",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "postal_code",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "search_vector",
"type": "tsvector"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "membership_fee_start_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "vereinfacht_contact_id",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "members_membership_fee_type_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "membership_fee_types"
},
"scale": null,
"size": null,
"source": "membership_fee_type_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"create_table_options": null,
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "4DF7F20D4C8D91E229906D6ADF87A4B5EB410672799753012DE4F0F49B470A51",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "members_unique_email_index",
"keys": [
{
"type": "atom",
"value": "email"
}
],
"name": "unique_email",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "members"
}

View file

@ -64,4 +64,4 @@
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "settings"
}
}

View file

@ -76,4 +76,4 @@
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "settings"
}
}

View file

@ -100,4 +100,4 @@
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "settings"
}
}

View file

@ -0,0 +1,140 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "club_name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "member_field_visibility",
"type": "map"
},
{
"allow_nil?": false,
"default": "true",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "include_joining_cycle",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "default_membership_fee_type_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "vereinfacht_api_url",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "vereinfacht_api_key",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "vereinfacht_club_id",
"type": "text"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
}
],
"base_filter": null,
"check_constraints": [],
"create_table_options": null,
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "1038A37F021DFC347E325042D613B0359FEB7DAFAE3286CBCEAA940A52B71217",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "settings"
}

View file

@ -0,0 +1,152 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "club_name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "member_field_visibility",
"type": "map"
},
{
"allow_nil?": false,
"default": "true",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "include_joining_cycle",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "default_membership_fee_type_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "vereinfacht_api_url",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "vereinfacht_api_key",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "vereinfacht_club_id",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "vereinfacht_app_url",
"type": "text"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
}
],
"base_filter": null,
"check_constraints": [],
"create_table_options": null,
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "4C29CEF273C1180162E7231A7F7CCE5DABD035E121648E48B6FBE30AE5191FF0",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "settings"
}

View file

@ -0,0 +1,164 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "club_name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "member_field_visibility",
"type": "map"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "member_field_required",
"type": "map"
},
{
"allow_nil?": false,
"default": "true",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "include_joining_cycle",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "default_membership_fee_type_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "vereinfacht_api_url",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "vereinfacht_api_key",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "vereinfacht_club_id",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "vereinfacht_app_url",
"type": "text"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
}
],
"base_filter": null,
"check_constraints": [],
"create_table_options": null,
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "C84FC81A2A446451D6B5EA72F9BBB3593CD7F0D71C4B7C9CE04934414FDB52EB",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "settings"
}

View file

@ -1,2 +1,2 @@
Vorname;Nachname;E-Mail;Straße;PLZ;Stadt
Max;Mustermann;max.mustermann@example.com;Hauptstraße;10115;Berlin
Vorname;Nachname;E-Mail;Land;Stadt;Straße;Hausnummer;PLZ;Beitrittsdatum;Austrittsdatum;Notizen;Beitragsbeginn
Max;Mustermann;max.mustermann@example.com;Deutschland;Berlin;Hauptstraße;12;10115;2020-01-15;;;

1 Vorname Nachname E-Mail Land Stadt Straße Hausnummer PLZ Beitrittsdatum Austrittsdatum Notizen Beitragsbeginn
2 Max Mustermann max.mustermann@example.com Deutschland Berlin Hauptstraße 12 10115 2020-01-15

View file

@ -1,2 +1,2 @@
first_name;last_name;email;street;postal_code;city
John;Doe;john.doe@example.com;Main Street;12345;Berlin
first_name;last_name;email;country;city;street;house_number;postal_code;join_date;exit_date;notes;membership_fee_start_date
John;Doe;john.doe@example.com;Germany;Berlin;Main Street;1a;12345;2020-01-15;;;

1 first_name last_name email country city street house_number postal_code join_date exit_date notes membership_fee_start_date
2 John Doe john.doe@example.com Germany Berlin Main Street 1a 12345 2020-01-15

View file

@ -103,13 +103,13 @@ defmodule Mv.Accounts.UserAuthenticationTest do
"preferred_username" => "oidc.user@example.com"
}
# Use sign_in_with_rauthy to find user by oidc_id
# Use sign_in_with_oidc to find user by oidc_id
# Note: This test will FAIL until we implement the security fix
# that changes the filter from email to oidc_id
system_actor = Mv.Helpers.SystemActor.get_system_actor()
result =
Mv.Accounts.read_sign_in_with_rauthy(
Mv.Accounts.read_sign_in_with_oidc(
%{
user_info: user_info,
oauth_tokens: %{}
@ -145,11 +145,11 @@ defmodule Mv.Accounts.UserAuthenticationTest do
"preferred_username" => "newuser@example.com"
}
# Should create via register_with_rauthy
# Should create via register_with_oidc
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, new_user} =
Mv.Accounts.create_register_with_rauthy(
Mv.Accounts.create_register_with_oidc(
%{
user_info: user_info,
oauth_tokens: %{}
@ -196,8 +196,8 @@ defmodule Mv.Accounts.UserAuthenticationTest do
describe "Mixed authentication scenarios" do
@tag :test_proposal
test "user with oidc_id cannot be found by email-only query in sign_in_with_rauthy" do
# This test verifies the security fix: sign_in_with_rauthy should NOT
test "user with oidc_id cannot be found by email-only query in sign_in_with_oidc" do
# This test verifies the security fix: sign_in_with_oidc should NOT
# match users by email, only by oidc_id
_user =
@ -218,7 +218,7 @@ defmodule Mv.Accounts.UserAuthenticationTest do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
result =
Mv.Accounts.read_sign_in_with_rauthy(
Mv.Accounts.read_sign_in_with_oidc(
%{
user_info: user_info,
oauth_tokens: %{}
@ -238,12 +238,12 @@ defmodule Mv.Accounts.UserAuthenticationTest do
:ok
other ->
flunk("sign_in_with_rauthy should not match by email alone, got: #{inspect(other)}")
flunk("sign_in_with_oidc should not match by email alone, got: #{inspect(other)}")
end
end
@tag :test_proposal
test "password user (oidc_id=nil) is not found by sign_in_with_rauthy" do
test "password user (oidc_id=nil) is not found by sign_in_with_oidc" do
# Create a password-only user
_user =
create_test_user(%{
@ -262,7 +262,7 @@ defmodule Mv.Accounts.UserAuthenticationTest do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
result =
Mv.Accounts.read_sign_in_with_rauthy(
Mv.Accounts.read_sign_in_with_oidc(
%{
user_info: user_info,
oauth_tokens: %{}
@ -283,7 +283,7 @@ defmodule Mv.Accounts.UserAuthenticationTest do
other ->
flunk(
"Password-only user should not be found by sign_in_with_rauthy, got: #{inspect(other)}"
"Password-only user should not be found by sign_in_with_oidc, got: #{inspect(other)}"
)
end
end

View file

@ -206,7 +206,7 @@ defmodule Mv.Accounts.UserEmailSyncTest do
# Simulate OIDC registration
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_rauthy, %{
|> Ash.Changeset.for_create(:register_with_oidc, %{
user_info: user_info,
oauth_tokens: oauth_tokens
})

View file

@ -80,15 +80,69 @@ defmodule Mv.Membership.MemberTest do
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
end
test "Postal code is optional but must have 5 digits if specified", %{actor: actor} do
attrs = Map.put(@valid_attrs, :postal_code, "1234")
test "Postal code is optional", %{actor: actor} do
attrs = Map.delete(@valid_attrs, :postal_code)
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
end
end
describe "Settings-driven required fields" do
@valid_attrs %{
first_name: "John",
last_name: "Doe",
email: "john@example.com"
}
setup do
{:ok, settings} = Membership.get_settings()
saved_visibility = settings.member_field_visibility || %{}
saved_required = settings.member_field_required || %{}
on_exit(fn ->
{:ok, s} = Membership.get_settings()
Membership.update_settings(s, %{
member_field_visibility: saved_visibility,
member_field_required: saved_required
})
end)
:ok
end
test "when first_name is required in settings, create without first_name fails", %{
actor: actor
} do
{:ok, settings} = Membership.get_settings()
{:ok, _} =
Membership.update_single_member_field(settings,
field: "first_name",
show_in_overview: true,
required: true
)
attrs = Map.delete(@valid_attrs, :first_name)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member(attrs, actor: actor)
assert error_message(errors, :postal_code) =~ "must consist of 5 digits"
attrs2 = Map.delete(@valid_attrs, :postal_code)
assert {:ok, _member} = Membership.create_member(attrs2, actor: actor)
assert error_message(errors, :first_name) =~ "can't be blank"
end
test "when first_name is required in settings, create with first_name succeeds", %{
actor: actor
} do
{:ok, settings} = Membership.get_settings()
{:ok, _} =
Membership.update_single_member_field(settings,
field: "first_name",
show_in_overview: true,
required: true
)
assert {:ok, _member} = Membership.create_member(@valid_attrs, actor: actor)
end
end

Some files were not shown because too many files have changed in this diff Show more