Compare commits

...

76 commits

Author SHA1 Message Date
4244779521
i18n: Complete German translations and standardize English msgstr
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-13 16:13:17 +01:00
70029f799e
i18n: Update POT and English translations 2026-01-13 15:22:24 +01:00
89fbd55250
refactor: Reduce nesting depth in UserLive.Form.load_members_for_linking 2026-01-13 15:21:00 +01:00
fba0ea5ec0
fix: Replace Ash.read! with error handling in CustomFieldValueLive.Index
- Replace Ash.read! with Ash.read and proper error handling in mount/3
2026-01-13 15:21:00 +01:00
807e03d86b
fix: Correct Language headers in German .po files 2026-01-13 15:20:58 +01:00
8610ab842a
ci: Add check for empty German translations in lint task
- Check that all German msgstr entries are filled (excluding header)
- Use awk to filter out header msgstr "" entries
- Fail lint if any empty translations are found
2026-01-13 15:20:20 +01:00
881157bd10
i18n: Add German and English translations for UI strings
- Fill in empty msgstr entries in German translations
- Add translations for user actions, error messages, and form labels
- Ensure all UI strings are consistently translated
2026-01-13 15:20:17 +01:00
eb81d5f7cb
refactor: Simplify UserLive.Form handle_event and improve error handling
- Extract handle_member_linking, perform_member_link_action helpers
- Extract handle_save_success, get_action_name, handle_member_link_error
- Replace hardcoded strings with gettext translations
- Use submit_form wrapper for consistent actor handling
- Group all handle_event/3 clauses together
- Add early return in load_members_for_linking if actor is nil
2026-01-13 15:17:07 +01:00
a22081f288
refactor: Replace bang calls with error handling in Index LiveViews
- Replace Ash.get!/Ash.destroy! with Ash.get/Ash.destroy
- Add case statements for Forbidden, NotFound, and generic errors
- Display user-friendly flash messages for all error cases
- Use Enum.map_join/3 for efficient error formatting
2026-01-13 15:17:07 +01:00
77ae5c4888
refactor: Use submit_form wrapper in all LiveView forms
- Replace AshPhoenix.Form.submit with submit_form/3 wrapper
- Import current_actor and submit_form from LiveHelpers
- Consistent actor handling in all form submissions
2026-01-13 15:17:06 +01:00
897677a782
refactor: Replace actor option patterns with ash_actor_opts helper
- Replace if actor, do: [actor: actor], else: [] with Mv.Helpers.ash_actor_opts/1
- Update email_sync/loader.ex, member validations, member.ex, cycle_generator.ex
- Consistent actor handling across non-LiveView modules
2026-01-13 15:17:06 +01:00
555ae15173
feat: Add shared helper functions for actor handling
- Add Mv.Helpers module with ash_actor_opts/1 helper
- Add current_actor/1 with @spec to LiveHelpers
- Add ash_actor_opts/1 delegate and submit_form/3 wrapper to LiveHelpers
- Standardize actor access pattern across LiveViews
2026-01-13 15:17:06 +01:00
970c749a92
test: Add role tag support to ConnCase and fix test issues
- Add role tag support (@tag role: :admin/:member/:unauthenticated) to ConnCase
- Fix Keyword.get -> Map.get for tags Map
- Remove duplicate test file index_display_name_test.exs
- Fix CustomField creation in tests (remove slug, use :string instead of :text)
- Fix CustomFieldValue value format to use _union_type/_union_value
2026-01-13 15:17:06 +01:00
351eac4c02
Fix error handling and actor access in MemberLive.Index
Replace bang calls with proper error handling and use current_actor/1
helper for consistent actor access.
2026-01-13 15:17:05 +01:00
145a76348c
Pass actor parameter in seeds and update test setup
Ensure cycle generation in seeds uses admin actor and update test
to use global admin_user from ConnCase setup.
2026-01-13 15:17:05 +01:00
9ecfe784db
Add missing Gettext translations for member deletion errors
Add German and English translations for member deletion success and
error messages.
2026-01-13 15:17:03 +01:00
cd7e6b0843
Use current_actor/1 helper in all LiveViews
Replace inconsistent actor access patterns with current_actor/1 helper
and ensure actor is passed to all Ash operations for proper authorization.
2026-01-13 15:16:00 +01:00
74fe60f768
Pass actor parameter to member email validation
Extract actor from changeset context and pass it to Ash.read and
Ash.load calls in email uniqueness validation.
2026-01-13 15:16:00 +01:00
5ffd2b334e
Pass actor parameter through email sync operations
Extract actor from changeset context and pass it to all email sync
loader functions to ensure proper authorization when loading linked
users and members.
2026-01-13 15:16:00 +01:00
dbd79075f5
Pass actor parameter through cycle generation
Extract actor from changeset context in Member hooks and pass it
through all cycle generation functions to ensure proper authorization.
2026-01-13 15:15:59 +01:00
01cc5aa3a1
Add current_actor/1 helper for consistent actor access
Provides a single function to access current_user from socket assigns
across all LiveViews, ensuring consistent access pattern.
2026-01-13 15:15:59 +01:00
075a06ba6f
Refactor test setup: use global setup and fix MembershipFees domain alias
- Remove redundant setup blocks from member_live tests
- Add build_unauthenticated_conn helper for AuthController tests
- Add global setup in conn_case.ex
2026-01-13 15:15:56 +01:00
bc87893134
Integrate Member policies in LiveViews
- Add on_mount hook to ensure user role is loaded in all Member LiveViews
- Pass actor parameter to all Ash operations (read, get, create, update, destroy, load)
2026-01-13 15:12:24 +01:00
dc3268cbf4
Fix: Update comment in auto_filter to reflect expr(false) usage
Update comment from 'id IN [] = never matches' to 'expr(false) = match none'
to match the actual implementation of deny_filter().
2026-01-13 15:01:56 +01:00
c95a6fac69
Improve: Make deny_filter robust and add regression test
- Change deny_filter from [id: {:in, []}] to expr(false)
- Add regression test to ensure deny-filter matches 0 records
2026-01-13 15:01:55 +01:00
42a463f422
Security: Fix critical deny-filter bug and improve authorization
CRITICAL FIX: Deny-filter was allowing all records instead of denying
Fix: User validation in Member now uses actor from changeset.context
2026-01-13 15:01:55 +01:00
b3eb6c9223
Docs: Correct :linked scope documentation 2026-01-13 15:01:55 +01:00
4fffeeaaa0
Fix: Seeds use admin actor instead of NoActor bypass
This ensures seeds work correctly with the new fail-closed NoActor
policy in production, using proper authorization instead of bypass.
2026-01-13 15:01:55 +01:00
6846363132
Refactor: NoActor to SimpleCheck with compile-time environment check
This prevents security issues where :create/:read without actor would
be allowed in production. Now all operations require an actor in production.
2026-01-13 15:01:54 +01:00
70729bdd73
Fix: HasPermission auto_filter and strict_check implementation
Fixes security issue where auto_filter returned nil instead of proper
filter expressions, which could lead to incorrect authorization behavior.
2026-01-13 15:01:54 +01:00
4192922fd3
feat: implement authorization policies for Member resource 2026-01-13 15:01:53 +01:00
93190d558f
test: add Member resource policy tests 2026-01-13 15:01:53 +01:00
22d50d6c46 Merge pull request 'add CSV teplate closes #329' (#347) from feature/329_csv_specification into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #347
2026-01-13 11:02:52 +01:00
469c4c0c1d i18n: update translations
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-13 10:55:09 +01:00
6fe75db56d formatting
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-13 10:50:33 +01:00
35895ac7fd fix tests
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-13 10:48:44 +01:00
720a43a38c feat: added csv templates
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-12 17:36:15 +01:00
3fd6410bb4
style: fix linting
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-12 15:37:58 +01:00
a1b0f65233 Merge pull request 'Add sidebar' (#260) from sidebar into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #260
2026-01-12 15:17:28 +01:00
8a1b14fc79
fix: fix tests and remove navbar remainings
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-12 15:16:31 +01:00
30805b07ca
chore: remove compose incompatibility with wsl2
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-12 14:16:08 +01:00
e7515b5450
Merge remote-tracking branch 'origin/main' into sidebar 2026-01-12 14:15:12 +01:00
06a05fcaad Merge pull request 'Implements settings for member fields closes #223' (#300) from feature/223_memberfields_settings into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #300
2026-01-12 13:24:52 +01:00
922f9f93d0 Merge branch 'main' into feature/223_memberfields_settings
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-12 13:15:40 +01:00
77908a1467 fix tests
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-12 11:45:44 +01:00
e38de7d690 chore: rename custom to data field in the UI
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-12 09:50:51 +01:00
6311eebb0c fix linting
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-08 11:41:24 +01:00
b0623b20ed style: remove navbar fixed width 2026-01-08 11:40:22 +01:00
47c46eaebf i18n: update translations 2026-01-08 11:40:04 +01:00
0ccb1c7d79 fix: add label for membership fee type 2026-01-08 11:39:16 +01:00
e565d1748e test: add tests for atomic member field visibility updates 2026-01-08 11:38:41 +01:00
b139d85791 fix: add missing event handler for member field visibility updates 2026-01-08 11:37:39 +01:00
30c43271ea refactor: remove code duplication using helper modules 2026-01-08 11:37:07 +01:00
4a1042ab1a feat: add atomic update for single member field visibility 2026-01-08 11:28:27 +01:00
9af7381843 refactor: extract helper modules to remove code duplication 2026-01-08 11:22:44 +01:00
36776f8e28 fix tests and linting 2026-01-07 18:11:36 +01:00
4a6e7cf51a feat: show only edit or list view in settings 2026-01-07 18:11:07 +01:00
38d106a69e fix: exit date as default hidden column 2026-01-07 12:14:41 +01:00
cbe05c5ca8 fix: cath all rauthy errors 2026-01-07 12:03:58 +01:00
df8c6a1854 Merge branch 'main' into feature/223_memberfields_settings
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-07 11:42:54 +01:00
909d4af2a2 Merge branch 'main' into feature/223_memberfields_settings 2026-01-07 11:11:02 +01:00
935ef52c10
style: fix linting issues
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-06 11:08:28 +01:00
ff625c91c5
Merge remote-tracking branch 'origin/main' into sidebar
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-06 10:52:55 +01:00
aba8737c38
feat: improve sidebar handling
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-06 10:29:20 +01:00
16ca4efc03
feat: implement standard-compliant sidebar with comprehensive tests
Some checks failed
continuous-integration/drone/push Build is failing
Implement a new sidebar component based on DaisyUI Drawer pattern without
custom CSS variants. The sidebar supports desktop (expanded/collapsed states)
and mobile (overlay drawer) with full accessibility compliance.

Sidebar Implementation:
- Refactor sidebar component with sidebar_header, menu_item, menu_group,
  sidebar_footer sub-components
- Add logo (mila.svg) with size-8 (32px) always visible
- Implement toggle button with icon swap (chevron-left/right) for desktop
- Add nested menu support with details/summary (expanded) and dropdown
  (collapsed) patterns
- Implement footer with language selector (expanded-only), theme toggle,
  and user menu with avatar
- Update layouts.ex to use drawer pattern with data-sidebar-expanded
  attribute for state management

CSS & JavaScript:
- Add CSS styles for sidebar state management via data-attribute selectors
- Implement SidebarState JavaScript hook for localStorage persistence
- Add smooth width transitions (w-64 ↔ w-16) for desktop collapsed state
- Add CSS classes for expanded-only, menu-label, and icon visibility

Documentation:
- Add sidebar-analysis-current-state.md: Analysis of current implementation
- Add sidebar-requirements-v2.md: Complete specification for new sidebar
- Add daisyui-drawer-pattern.md: DaisyUI pattern documentation
- Add umsetzung-sidebar.md: Step-by-step implementation guide

Testing:
- Add comprehensive component tests for all sidebar sub-components
- Add integration tests for sidebar state management and mobile drawer
- Extend accessibility tests (ARIA labels, roles, keyboard navigation)
- Add regression tests for duplicate IDs, hover effects, and tooltips
- Ensure full test coverage per specification requirements
2025-12-18 16:36:16 +01:00
e2c5971daf chore: updates translation
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-16 17:16:44 +01:00
c88f805b6e style: combines member and custom fields in settings 2025-12-16 17:16:29 +01:00
5fa0b48acc feat: adds form for member fields 2025-12-16 17:12:26 +01:00
18c082a893 chore: updated translation
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-15 10:50:36 +01:00
e088123fb9 feat: adds required column to custom field settings 2025-12-15 09:58:38 +01:00
3d81461fbe feat: adds memberdata component for settings 2025-12-15 09:58:19 +01:00
756d99dcc8 test: adds tests 2025-12-15 09:54:52 +01:00
eea3f28cc5 test: added tests 2025-12-15 09:33:47 +01:00
b0097ab99d feat: adds sidebar as overlay and moves navbar content there
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-11 10:11:00 +01:00
bb6ea0085b Merge branch 'main' into sidebar
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-12-08 12:29:33 +01:00
2f6d5ff818
Add simple sidebar
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-03 22:41:57 +01:00
92 changed files with 11188 additions and 2260 deletions

View file

@ -32,6 +32,8 @@ lint:
mix format --check-formatted mix format --check-formatted
mix compile --warnings-as-errors mix compile --warnings-as-errors
mix credo mix credo
# Check that all German translations are filled (UI must be in German)
@bash -c 'for file in priv/gettext/de/LC_MESSAGES/*.po; do awk "/^msgid \"\"$/{header=1; next} /^msgid /{header=0} /^msgstr \"\"$/ && !header{print FILENAME\":\"NR\": \" \$0; exit 1}" "$file" || exit 1; done'
mix gettext.extract --check-up-to-date mix gettext.extract --check-up-to-date
audit: audit:

View file

@ -99,4 +99,213 @@
/* Make LiveView wrapper divs transparent for layout */ /* Make LiveView wrapper divs transparent for layout */
[data-phx-session] { display: contents } [data-phx-session] { display: contents }
/* ============================================
Sidebar Base Styles
============================================ */
/* Desktop Sidebar Base */
.sidebar {
@apply flex flex-col bg-base-200 min-h-screen;
@apply transition-[width] duration-300 ease-in-out;
@apply relative;
width: 16rem; /* Expanded: w-64 */
z-index: 40;
}
/* Collapsed State */
[data-sidebar-expanded="false"] .sidebar {
width: 4rem; /* Collapsed: w-16 */
}
/* ============================================
Header - Logo Centering
============================================ */
/* Header container with smooth transition for gap */
.sidebar > div:first-child {
@apply transition-all duration-300;
}
/* ============================================
Text Labels - Hide in Collapsed State
============================================ */
.menu-label {
@apply transition-all duration-200 whitespace-nowrap;
transition-delay: 0ms; /* Expanded: sofort sichtbar */
}
[data-sidebar-expanded="false"] .sidebar .menu-label {
@apply opacity-0 w-0 overflow-hidden pointer-events-none;
transition-delay: 300ms; /* Warte bis Sidebar eingeklappt ist (300ms = duration der Sidebar width transition) */
}
/* ============================================
Toggle Button Icon Swap
============================================ */
.sidebar-collapsed-icon {
@apply hidden;
}
[data-sidebar-expanded="false"] .sidebar .sidebar-expanded-icon {
@apply hidden;
}
[data-sidebar-expanded="false"] .sidebar .sidebar-collapsed-icon {
@apply block;
}
/* ============================================
Menu Groups - Show/Hide Based on State
============================================ */
.expanded-menu-group {
@apply block;
}
.collapsed-menu-group {
@apply hidden;
}
[data-sidebar-expanded="false"] .sidebar .expanded-menu-group {
@apply hidden;
}
[data-sidebar-expanded="false"] .sidebar .collapsed-menu-group {
@apply block;
}
/* Collapsed menu group button: center icon under logo */
.sidebar .collapsed-menu-group button {
padding-left: 14px;
}
/* ============================================
Elements Only Visible in Expanded State
============================================ */
.expanded-only {
@apply block transition-opacity duration-200;
}
[data-sidebar-expanded="false"] .sidebar .expanded-only {
@apply hidden;
}
/* ============================================
Tooltip - Only Show in Collapsed State
============================================ */
.sidebar .tooltip::before,
.sidebar .tooltip::after {
@apply opacity-0 pointer-events-none;
}
[data-sidebar-expanded="false"] .sidebar .tooltip:hover::before,
[data-sidebar-expanded="false"] .sidebar .tooltip:hover::after {
@apply opacity-100;
}
/* ============================================
Menu Item Alignment - Icons Centered Under Logo
============================================ */
/* Base alignment: Icons centered under logo (32px from left edge)
- Logo center: 16px padding + 16px (half of 32px) = 32px
- Icon center should be at 32px: 22px start + 10px (half of 20px) = 32px
- Menu has p-2 (8px), so links need 14px additional padding-left */
.sidebar .menu > li > a,
.sidebar .menu > li > button {
@apply transition-all duration-300;
padding-left: 14px;
}
/* Collapsed state: same padding to keep icons at same position
- Remove gap so label (which is opacity-0 w-0) doesn't create space
- Keep padding-left at 14px so icons stay centered under logo */
[data-sidebar-expanded="false"] .sidebar .menu > li > a,
[data-sidebar-expanded="false"] .sidebar .menu > li > button {
@apply gap-0;
padding-left: 14px;
padding-right: 14px; /* Center icon horizontally in 64px sidebar */
}
/* ============================================
Footer Button Alignment - Left Aligned in Collapsed State
============================================ */
[data-sidebar-expanded="false"] .sidebar .dropdown > button {
@apply px-0;
/* Buttons stay at left position, only label disappears */
}
/* ============================================
User Menu Button - Focus Ring on Avatar
============================================ */
/* Focus ring appears on the avatar when button is focused */
.user-menu-button:focus .avatar > div {
@apply ring-2 ring-primary ring-offset-2 ring-offset-base-200;
}
/* ============================================
User Menu Button - Smooth Centering Transition
============================================ */
/* User menu button transitions smoothly to center */
.user-menu-button {
@apply transition-all duration-300;
}
/* In collapsed state, center avatar under logo
- Avatar is 32px (w-8), center it in 64px sidebar
- (64px - 32px) / 2 = 16px padding avatar center at 32px (same as logo center) */
[data-sidebar-expanded="false"] .sidebar .user-menu-button {
@apply gap-0;
padding-left: 16px;
padding-right: 16px;
justify-content: center;
}
/* ============================================
User Menu Button - Hover Ring on Avatar
============================================ */
/* Smooth transition for avatar ring effects */
.user-menu-button .avatar > div {
@apply transition-all duration-200;
}
/* Hover ring appears on the avatar when button is hovered */
.user-menu-button:hover .avatar > div {
@apply ring-1 ring-neutral ring-offset-1 ring-offset-base-200;
}
/* ============================================
Mobile Drawer Width
============================================ */
/* Auf Mobile (< 1024px) ist die Sidebar immer w-64 (16rem) wenn geöffnet */
@media (max-width: 1023px) {
.drawer-side .sidebar {
width: 16rem; /* w-64 auch auf Mobile */
}
}
/* ============================================
Drawer Side Overflow Fix für Desktop
============================================ */
/* Im Desktop-Modus (lg:drawer-open) overflow auf visible setzen
damit Dropdowns und Tooltips über Main Content erscheinen können */
@media (min-width: 1024px) {
.drawer.lg\:drawer-open .drawer-side {
overflow: visible !important;
overflow-x: visible !important;
overflow-y: visible !important;
}
}
/* This file is for your main application CSS */ /* This file is for your main application CSS */

View file

@ -73,6 +73,43 @@ Hooks.ComboBox = {
} }
} }
// SidebarState hook: Manages sidebar expanded/collapsed state
Hooks.SidebarState = {
mounted() {
// Restore state from localStorage
const expanded = localStorage.getItem('sidebar-expanded') !== 'false'
this.setSidebarState(expanded)
// Expose toggle function globally
window.toggleSidebar = () => {
const current = this.el.dataset.sidebarExpanded === 'true'
this.setSidebarState(!current)
}
},
setSidebarState(expanded) {
// Convert boolean to string for consistency
const expandedStr = expanded ? 'true' : 'false'
// Update data-attribute (CSS reacts to this)
this.el.dataset.sidebarExpanded = expandedStr
// Persist to localStorage
localStorage.setItem('sidebar-expanded', expandedStr)
// Update ARIA for accessibility
const toggleBtn = document.getElementById('sidebar-toggle')
if (toggleBtn) {
toggleBtn.setAttribute('aria-expanded', expandedStr)
}
},
destroyed() {
// Cleanup
delete window.toggleSidebar
}
}
let liveSocket = new LiveSocket("/live", Socket, { let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500, longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken}, params: {_csrf_token: csrfToken},
@ -102,3 +139,170 @@ liveSocket.connect()
// >> liveSocket.disableLatencySim() // >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket window.liveSocket = liveSocket
// Sidebar accessibility improvements
document.addEventListener("DOMContentLoaded", () => {
const drawerToggle = document.getElementById("mobile-drawer")
const sidebarToggle = document.getElementById("sidebar-toggle")
const sidebar = document.getElementById("main-sidebar")
if (!drawerToggle || !sidebarToggle || !sidebar) return
// Manage tabindex for sidebar elements based on open/closed state
const updateSidebarTabIndex = (isOpen) => {
// Find all potentially focusable elements (including those with tabindex="-1")
const allFocusableElements = sidebar.querySelectorAll(
'a[href], button, select, input:not([type="hidden"]), [tabindex]'
)
allFocusableElements.forEach(el => {
// Skip the overlay button
if (el.closest('.drawer-overlay')) return
if (isOpen) {
// Remove tabindex="-1" to make focusable when open
if (el.hasAttribute('tabindex') && el.getAttribute('tabindex') === '-1') {
el.removeAttribute('tabindex')
}
} else {
// Set tabindex="-1" to remove from tab order when closed
if (!el.hasAttribute('tabindex')) {
el.setAttribute('tabindex', '-1')
} else if (el.getAttribute('tabindex') !== '-1') {
// Store original tabindex in data attribute before setting to -1
if (!el.hasAttribute('data-original-tabindex')) {
el.setAttribute('data-original-tabindex', el.getAttribute('tabindex'))
}
el.setAttribute('tabindex', '-1')
}
}
})
}
// Find first focusable element in sidebar
// Priority: first navigation link (menuitem) > other links > other focusable elements
const getFirstFocusableElement = () => {
// First, try to find the first navigation link (menuitem)
const firstNavLink = sidebar.querySelector('a[href][role="menuitem"]:not([tabindex="-1"])')
if (firstNavLink && !firstNavLink.closest('.drawer-overlay')) {
return firstNavLink
}
// Fallback: any navigation link
const firstLink = sidebar.querySelector('a[href]:not([tabindex="-1"])')
if (firstLink && !firstLink.closest('.drawer-overlay')) {
return firstLink
}
// Last resort: any other focusable element
const focusableSelectors = [
'button:not([tabindex="-1"]):not([disabled])',
'select:not([tabindex="-1"]):not([disabled])',
'input:not([tabindex="-1"]):not([disabled]):not([type="hidden"])',
'[tabindex]:not([tabindex="-1"])'
]
for (const selector of focusableSelectors) {
const element = sidebar.querySelector(selector)
if (element && !element.closest('.drawer-overlay')) {
return element
}
}
return null
}
// Update aria-expanded when drawer state changes
const updateAriaExpanded = () => {
const isOpen = drawerToggle.checked
sidebarToggle.setAttribute("aria-expanded", isOpen.toString())
// Update dropdown aria-expanded if present
const userMenuButton = sidebar.querySelector('button[aria-haspopup="true"]')
if (userMenuButton) {
const dropdown = userMenuButton.closest('.dropdown')
const isDropdownOpen = dropdown?.classList.contains('dropdown-open')
if (userMenuButton) {
userMenuButton.setAttribute("aria-expanded", (isDropdownOpen || false).toString())
}
}
}
// Listen for changes to the drawer checkbox
drawerToggle.addEventListener("change", () => {
const isOpen = drawerToggle.checked
updateAriaExpanded()
updateSidebarTabIndex(isOpen)
if (!isOpen) {
// When closing, return focus to toggle button
sidebarToggle.focus()
}
})
// Update on initial load
updateAriaExpanded()
updateSidebarTabIndex(drawerToggle.checked)
// Close sidebar with ESC key
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && drawerToggle.checked) {
drawerToggle.checked = false
updateAriaExpanded()
updateSidebarTabIndex(false)
// Return focus to toggle button
sidebarToggle.focus()
}
})
// Improve keyboard navigation for sidebar toggle
sidebarToggle.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
const wasOpen = drawerToggle.checked
drawerToggle.checked = !drawerToggle.checked
updateAriaExpanded()
// If opening, move focus to first element in sidebar
if (!wasOpen && drawerToggle.checked) {
updateSidebarTabIndex(true)
// Use setTimeout to ensure DOM is updated
setTimeout(() => {
const firstElement = getFirstFocusableElement()
if (firstElement) {
firstElement.focus()
}
}, 50)
} else if (wasOpen && !drawerToggle.checked) {
updateSidebarTabIndex(false)
}
}
})
// Also handle click events to update tabindex and focus
sidebarToggle.addEventListener("click", () => {
setTimeout(() => {
const isOpen = drawerToggle.checked
updateSidebarTabIndex(isOpen)
if (isOpen) {
const firstElement = getFirstFocusableElement()
if (firstElement) {
firstElement.focus()
}
}
}, 50)
})
// Handle dropdown keyboard navigation
const userMenuButton = sidebar?.querySelector('button[aria-haspopup="true"]')
if (userMenuButton) {
userMenuButton.addEventListener("click", () => {
setTimeout(updateAriaExpanded, 0)
})
userMenuButton.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
userMenuButton.click()
}
})
}
})

View file

@ -1,318 +0,0 @@
---
name: Code Review Fixes - Membership Fee Features
overview: Umsetzung der validen Code Review Punkte aus beiden Reviews mit Priorisierung nach Kritikalität. Fokus auf Transaktionssicherheit, Code-Qualität, Performance und UX-Verbesserungen.
todos:
- id: fix-after-action-tasks
content: "after_action mit Task.start → after_transaction + Task.Supervisor: Task.Supervisor zu application.ex hinzufügen, after_action Hooks in after_transaction umwandeln, Task.Supervisor.async_nolink verwenden"
status: pending
- id: reduce-code-duplication
content: "Code-Duplikation reduzieren: handle_cycle_generation/2 private Funktion extrahieren, alle drei Stellen (Create, Type Change, Date Change) verwenden"
status: pending
dependencies:
- fix-after-action-tasks
- id: fix-join-date-validation
content: "join_date Validierung: Entweder Validierung wieder hinzufügen (validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0)) oder Dokumentation anpassen"
status: pending
- id: fix-load-cycles-docs
content: "load_cycles_for_members: Entweder Dokumentation korrigieren (ehrlich machen) oder echte Filterung implementieren (z.B. nur letzte 2 Intervalle)"
status: pending
- id: fix-get-current-cycle-sort
content: "get_current_cycle nondeterministisch: Vor List.first() nach cycle_start sortieren (desc) in MembershipFeeHelpers.get_current_cycle"
status: pending
- id: fix-n1-query-member-count
content: "N+1 Query beheben: Aggregate auf MembershipFeeType definieren oder member_count einmalig vorab laden und in assigns cachen"
status: pending
- id: fix-assign-new-stale
content: "assign_new → assign: In MembershipFeesComponent.update/2 immer assign(:cycles, cycles) und assign(:available_fee_types, available_fee_types) setzen"
status: pending
- id: fix-regenerating-flag
content: "@regenerating auf true setzen: Direkt beim Event-Start in handle_event(\"regenerate_cycles\", ...) socket |> assign(:regenerating, true) setzen"
status: pending
- id: fix-create-cycle-parsing
content: "Create-cycle parsing Fix: Decimal.parse explizit behandeln und {:error, :invalid_amount} zurückgeben statt :error"
status: pending
- id: fix-delete-all-atomic
content: "Delete all cycles atomar: Bulk Delete Query verwenden (Ash bulk destroy oder Query-basiert) statt Enum.map"
status: pending
- id: improve-async-error-handling
content: "Fehlerbehandlung bei async Tasks: Strukturierte Error-Logs mit Context, optional Retry-Mechanismus oder Event-System für Benachrichtigung"
status: pending
- id: improve-format-currency
content: "format_currency Robustheit: Number.Currency verwenden oder robusteres Pattern Matching + Tests für Edge Cases (negative Zahlen, sehr große Zahlen)"
status: pending
- id: add-missing-typespecs
content: "Fehlende Typespecs: @spec für SetDefaultMembershipFeeType.change/3 hinzufügen"
status: pending
- id: fix-race-condition
content: "Potenzielle Race Condition: Prüfen ob Ash doppelte Auslösung verhindert, ggf. Logik anpassen (beide Änderungen in einem Hook zusammenfassen)"
status: pending
- id: extract-magic-values
content: "Magic Numbers/Strings: Application.get_env(:mv, :sql_sandbox, false) in Konstante/Helper extrahieren (z.B. Mv.Config.sql_sandbox?/0)"
status: pending
- id: fix-domain-consistency
content: "Domain-Konsistenz: Überall in MembershipFeesComponent domain: MembershipFees explizit angeben"
status: pending
- id: fix-test-helper
content: "Test-Helper Fix: create_cycle/3 Helper - Cleanup nur einmal im Setup oder gezielt nur auto-generierte löschen"
status: pending
- id: fix-date-utc-today-param
content: "Date.utc_today() Parameter: today Parameter durchgeben in get_cycle_status_for_member und Helper-Funktionen"
status: pending
- id: fix-ui-locale-input
content: "UI/Locale Input Fix: type=\"number\" → type=\"text\" + inputmode=\"decimal\" + serverseitig \",\" → \".\" normalisieren"
status: pending
- id: fix-delete-confirmation
content: "Delete-all-Confirmation robuster: String.trim() + case-insensitive Vergleich oder \"type DELETE\" Pattern"
status: pending
- id: fix-warning-state
content: "Warning-State Fix: Bei Decimal.parse(:error) explizit hide_amount_warning(socket) aufrufen"
status: pending
- id: fix-double-toggle
content: "Toggle entfernen: Toggle-Button im Spalten-Header entfernen (nur in Toolbar behalten)"
status: pending
- id: fix-format-consistency
content: "Format-Konsistenz: Inputs ebenfalls auf Komma ausrichten oder serverseitig normalisieren"
status: pending
dependencies:
- fix-ui-locale-input
---
# Code Review Fixes - Membership Fee Features
## Kritische Probleme (Müssen vor Merge behoben werden)
### 1. after_action mit Task.start - Transaktionsprobleme
**Dateien:** `lib/membership/member.ex` (Zeilen 142, 279)
**Problem:** `Task.start/1` wird innerhalb von `after_action` Hooks verwendet. `after_action` läuft innerhalb der DB-Transaktion, daher:
- Tasks sehen möglicherweise noch nicht committed state
- Tasks werden auch bei Rollback gestartet
- Keine Supervision → Memory Leaks möglich
**Lösung:**
- `after_transaction` Hook verwenden (Ash Best Practice)
- `Task.Supervisor` zum Supervision Tree hinzufügen (`lib/mv/application.ex`)
- `Task.Supervisor.async_nolink/3` statt `Task.start/1` verwenden
**Betroffene Stellen:**
- Member Creation (Zeile 116-164)
- Join/Exit Date Change (Zeile 250-301)
### 2. Code-Duplikation in Cycle-Generation-Logik
**Datei:** `lib/membership/member.ex`
**Problem:** Cycle-Generation-Logik ist dreimal dupliziert (Create, Type Change, Date Change)
**Lösung:** Extrahiere in private Funktion `handle_cycle_generation/2`
## Wichtige Probleme (Sollten behoben werden)
### 3. join_date Validierung entfernt, aber Dokumentation behauptet Gegenteil
**Datei:** `lib/membership/member.ex` (Zeile 27, 516-518)
**Problem:** Dokumentation sagt "join_date not in future", aber Validierung fehlt
**Lösung:** Dokumentation anpassen
### 4. load_cycles_for_members overpromises
**Datei:** `lib/mv_web/member_live/index/membership_fee_status.ex` (Zeile 36-40)
**Problem:** Dokumentation sagt "Only loads the relevant cycle per member" und "Filters cycles at database level", aber lädt alle Cycles
**Lösung:** echte Filterung implementieren (z.B. nur letzte 2 Intervalle)
### 5. get_current_cycle nondeterministisch
**Datei:** `lib/mv_web/helpers/membership_fee_helpers.ex` (Zeile 178-182)
**Problem:** `List.first()` ohne explizite Sortierung → Ergebnis hängt von Reihenfolge ab
**Lösung:** Vor `List.first()` nach `cycle_start` sortieren (desc)
### 6. N+1 Query durch get_member_count
**Datei:** `lib/mv_web/live/membership_fee_type_live/index.ex` (Zeile 134-140)
**Problem:** `get_member_count/1` wird pro Row aufgerufen → N+1 Query
**Lösung:** Aggregate auf MembershipFeeType definieren oder einmalig vorab laden
### 7. assign_new kann stale werden
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 402-403)
**Problem:** `assign_new(:cycles, ...)` und `assign_new(:available_fee_types, ...)` werden nur gesetzt, wenn Assign noch nicht existiert
**Lösung:** In `update/2` immer `assign(:cycles, cycles)` / `assign(:available_fee_types, available_fee_types)` setzen
### 8. @regenerating wird nie auf true gesetzt
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 526-561)
**Problem:** `regenerating` wird nur auf `false` gesetzt, nie auf `true` → Button/Spinner werden nie disabled
**Lösung:** Direkt beim Event-Start `socket |> assign(:regenerating, true)` setzen
### 9. Create-cycle parsing: invalid amount zeigt falsche Fehlermeldung
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 748-812)
**Problem:** `Decimal.parse/1` gibt `:error` zurück, aber `with` behandelt es als `:error` → landet in "Invalid date format" Branch
**Lösung:** Explizit `{:error, :invalid_amount}` zurückgeben:
```elixir
amount = case Decimal.parse(amount_str) do
{d, _} -> {:ok, d}
:error -> {:error, :invalid_amount}
end
```
### 10. Delete all cycles: nicht atomar
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 666-714)
**Problem:** `Enum.map(cycles, &Ash.destroy/1)` → nicht atomar, teilweise gelöscht möglich
**Lösung:** Bulk Delete Query verwenden (Ash bulk destroy oder Query-basiert)
### 11. Fehlerbehandlung bei async Tasks
**Datei:** `lib/membership/member.ex`
**Problem:** Bei Fehlern in async Tasks wird nur geloggt, aber der Benutzer erhält keine Rückmeldung. Die Member-Aktion wird als erfolgreich zurückgegeben, auch wenn die Cycle-Generierung fehlschlägt. Keine Retry-Logik oder Monitoring.
**Lösung:**
- Für kritische Fälle: synchron ausführen oder Retry-Mechanismus implementieren
- Für nicht-kritische Fälle: Event-System für spätere Benachrichtigung
- Strukturierte Error-Logs mit Context
- Optional: Error-Tracking (Sentry, etc.)
### 12. format_currency Robustheit
**Datei:** `lib/mv_web/helpers/membership_fee_helpers.ex` (Zeilen 27-51)
**Problem:** Die Funktion verwendet String-Manipulation für Formatierung. Edge Cases könnten problematisch sein (z.B. sehr große Zahlen, negative Werte).
**Lösung:**
- `Number.Currency` oder ähnliche Bibliothek verwenden
- Oder: Robusteres Pattern Matching für Edge Cases
- Tests für Edge Cases hinzufügen (negative Zahlen, sehr große Zahlen)
### 13. Fehlende Typespecs
**Datei:** `lib/membership/member/changes/set_default_membership_fee_type.ex`
**Problem:** Keine `@spec` für die `change/3` Funktion.
**Lösung:** Typespecs hinzufügen für bessere Dokumentation und Dialyzer-Support.
### 14. Potenzielle Race Condition
**Datei:** `lib/membership/member.ex` (Zeile 250-301)
**Problem:** Wenn `join_date` und `exit_date` gleichzeitig geändert werden, könnte die Cycle-Generierung zweimal ausgelöst werden (einmal pro Änderung).
**Lösung:** Prüfen, ob Ash dies bereits verhindert, oder Logik anpassen (z.B. beide Änderungen in einem Hook zusammenfassen).
### 15. Magic Numbers/Strings
**Problem:** `Application.get_env(:mv, :sql_sandbox, false)` wird mehrfach verwendet.
**Lösung:** Extrahiere in Konstante oder Helper-Funktion (z.B. `Mv.Config.sql_sandbox?/0`).
## Mittlere Probleme (Nice-to-have)
### 16. Inconsistent use of domain
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 819-821)
**Problem:** Einige Actions verwenden `domain: MembershipFees`, andere nicht
**Lösung:** Konsistent `domain` überall verwenden
### 17. Tests: create_cycle/3 löscht jedes Mal alle Cycles
**Datei:** `test/mv_web/member_live/index/membership_fee_status_test.exs` (Zeile 45-52)
**Problem:** Helper löscht vor jedem Create alle Cycles → Tests prüfen nicht, was sie denken
**Lösung:** Cleanup nur einmal im Setup oder gezielt nur auto-generierte löschen
### 18. Tests/Design: Date.utc_today() macht Tests flaky
**Problem:** Tests hängen von `Date.utc_today()` ab → nicht deterministisch
**Lösung:** `today` Parameter durchgeben (z.B. `get_cycle_status_for_member(member, show_current, today \\ Date.utc_today())`)
### 19. UI/Locale: input type="number" + Decimal/Komma
**Problem:** `type="number"` funktioniert nicht zuverlässig mit Komma als Dezimaltrenner
**Lösung:** `type="text"` + `inputmode="decimal"` + serverseitig "," → "." normalisieren
### 20. Delete-all-Confirmation: String-Vergleich ist fragil
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 296-298)
**Problem:** String-Vergleich gegen `gettext("Yes")` und `"Yes"` → fragil bei Whitespace/Locale
**Lösung:** `String.trim()` + case-insensitive Vergleich oder "type DELETE" Pattern
### 21. MembershipFeeType Form: Warning-State kann hängen bleiben
**Datei:** `lib/mv_web/live/membership_fee_type_live/form.ex` (Zeile 367-378)
**Problem:** Bei `Decimal.parse(:error)` wird nur `socket` zurückgegeben → Warning kann stehen bleiben
**Lösung:** Bei `:error` explizit `hide_amount_warning(socket)` aufrufen
### 22. UI/UX: Toggle ist doppelt vorhanden
**Datei:** `lib/mv_web/live/member_live/index.html.heex` (Zeile 45-72, 284-296)
**Problem:** Toggle-Button sowohl in Toolbar als auch im Spalten-Header
**Lösung:** Toggle im Spalten-Header entfernen (nur in Toolbar behalten)
### 23. Konsistenz: format_currency vs Inputs
**Problem:** `format_currency` formatiert deutsch (Komma), aber Inputs erwarten Punkt
**Lösung:** Inputs ebenfalls auf Komma ausrichten oder serverseitig normalisieren
## Implementierungsreihenfolge
1. **Kritisch:** after_action → after_transaction + Task.Supervisor
2. **Kritisch:** Code-Duplikation reduzieren
3. **Wichtig:** join_date Validierung/Dokumentation
4. **Wichtig:** load_cycles_for_members Dokumentation/Implementierung
5. **Wichtig:** get_current_cycle Sortierung
6. **Wichtig:** N+1 Query beheben
7. **Wichtig:** assign_new → assign
8. **Wichtig:** @regenerating auf true setzen
9. **Wichtig:** Create-cycle parsing Fix
10. **Wichtig:** Delete all cycles atomar
11. **Wichtig:** Fehlerbehandlung bei async Tasks
12. **Wichtig:** format_currency Robustheit
13. **Wichtig:** Fehlende Typespecs
14. **Wichtig:** Potenzielle Race Condition prüfen/beheben
15. **Wichtig:** Magic Numbers/Strings extrahieren
16. **Mittel:** Domain-Konsistenz
17. **Mittel:** Test-Helper Fix
18. **Mittel:** Date.utc_today() Parameter
19. **Mittel:** UI/Locale Fixes
20. **Mittel:** String-Vergleich robuster
21. **Mittel:** Warning-State Fix
22. **Mittel:** Toggle entfernen
23. **Mittel:** Format-Konsistenz

View file

@ -10,11 +10,7 @@ services:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_DB: mv_dev POSTGRES_DB: mv_dev
volumes: volumes:
- type: volume - postgres-data:/var/lib/postgresql/data
source: postgres-data
target: /var/lib/postgresql/data
volume:
nocopy: true
ports: ports:
- "5000:5432" - "5000:5432"
networks: networks:
@ -49,9 +45,7 @@ services:
- rauthy-dev - rauthy-dev
- local - local
volumes: volumes:
- type: volume - rauthy-data:/app/data
source: rauthy-data
target: /app/data
volumes: volumes:
postgres-data: postgres-data:

View file

@ -191,6 +191,26 @@ A **basic CSV member import feature** that allows administrators to upload a CSV
- `/templates/member_import_de.csv` - `/templates/member_import_de.csv`
- In LiveView, link them using Phoenix static path helpers (e.g. `~p` or `Routes.static_path/2`, depending on Phoenix version). - In LiveView, link them using Phoenix static path helpers (e.g. `~p` or `Routes.static_path/2`, depending on Phoenix version).
**Example Usage in LiveView Templates:**
```heex
<!-- Using ~p sigil (Phoenix 1.7+) -->
<.link href={~p"/templates/member_import_en.csv"} download>
<%= gettext("Download English Template") %>
</.link>
<.link href={~p"/templates/member_import_de.csv"} download>
<%= gettext("Download German Template") %>
</.link>
<!-- Alternative: Using Routes.static_path/2 -->
<.link href={Routes.static_path(MvWeb.Endpoint, "/templates/member_import_en.csv")} download>
<%= gettext("Download English Template") %>
</.link>
```
**Note:** The `templates` directory must be included in `MvWeb.static_paths()` (configured in `lib/mv_web.ex`) for the files to be served.
### File Limits ### File Limits
- **Max file size:** 10 MB - **Max file size:** 10 MB

View file

@ -0,0 +1,533 @@
# DaisyUI Drawer Pattern - Standard Implementation
This document describes the standard DaisyUI drawer pattern for implementing responsive sidebars. It covers mobile overlay drawers, desktop persistent sidebars, and their combination.
## Core Concept
DaisyUI's drawer component uses a **checkbox-based toggle mechanism** combined with CSS to create accessible, responsive sidebars without custom JavaScript.
### Key Components
1. **`drawer`** - Container element
2. **`drawer-toggle`** - Hidden checkbox that controls open/close state
3. **`drawer-content`** - Main content area
4. **`drawer-side`** - Sidebar content (menu, navigation)
5. **`drawer-overlay`** - Optional overlay for mobile (closes drawer on click)
## HTML Structure
```html
<div class="drawer">
<!-- Hidden checkbox controls the drawer state -->
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
<!-- Main content area -->
<div class="drawer-content">
<!-- Page content goes here -->
<label for="my-drawer" class="btn btn-primary">Open drawer</label>
</div>
<!-- Sidebar content -->
<div class="drawer-side">
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<ul class="menu p-4 w-80 min-h-full bg-base-200 text-base-content">
<!-- Sidebar content goes here -->
<li><a>Sidebar Item 1</a></li>
<li><a>Sidebar Item 2</a></li>
</ul>
</div>
</div>
```
## How drawer-toggle Works
### Mechanism
The `drawer-toggle` is a **hidden checkbox** that serves as the state controller:
```html
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
```
### Toggle Behavior
1. **Label Connection**: Any `<label for="my-drawer">` element can toggle the drawer
2. **Checkbox State**:
- `checked` → drawer is open
- `unchecked` → drawer is closed
3. **CSS Targeting**: DaisyUI uses CSS sibling selectors to show/hide the drawer based on checkbox state
4. **Accessibility**: Native checkbox provides keyboard accessibility (Space/Enter to toggle)
### Toggle Examples
```html
<!-- Button to open drawer -->
<label for="my-drawer" class="btn btn-primary drawer-button">
Open Menu
</label>
<!-- Close button inside drawer -->
<label for="my-drawer" class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></label>
<!-- Overlay to close (click outside) -->
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
```
## Mobile Drawer (Overlay)
### Characteristics
- Drawer slides in from the side (usually left)
- Overlays the main content
- Dark overlay (drawer-overlay) behind drawer
- Clicking overlay closes the drawer
- Typically used on mobile/tablet screens
### Implementation
```html
<div class="drawer">
<input id="mobile-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content">
<!-- Toggle button in header -->
<div class="navbar bg-base-100">
<div class="flex-none">
<label for="mobile-drawer" class="btn btn-square btn-ghost">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-5 h-5 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</label>
</div>
<div class="flex-1">
<a class="btn btn-ghost text-xl">My App</a>
</div>
</div>
<!-- Main content -->
<div class="p-4">
<h1>Main Content</h1>
</div>
</div>
<div class="drawer-side">
<!-- Overlay - clicking it closes the drawer -->
<label for="mobile-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<!-- Sidebar menu -->
<ul class="menu p-4 w-80 min-h-full bg-base-200">
<li><a>Home</a></li>
<li><a>About</a></li>
<li><a>Contact</a></li>
</ul>
</div>
</div>
```
### Styling Notes
- **Width**: Default `w-80` (320px), adjust with Tailwind width utilities
- **Background**: Use DaisyUI color classes like `bg-base-200`
- **Height**: Always use `min-h-full` to ensure full height
- **Padding**: Add `p-4` or similar for inner spacing
## Desktop Sidebar (Persistent)
### Characteristics
- Always visible (no overlay)
- Does not overlay main content
- Main content adjusts to sidebar width
- No toggle button needed
- Used on desktop screens
### Implementation with drawer-open
```html
<div class="drawer lg:drawer-open">
<input id="desktop-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content">
<!-- Main content -->
<div class="p-4">
<h1>Main Content</h1>
<p>The sidebar is always visible on desktop (lg and above)</p>
</div>
</div>
<div class="drawer-side">
<!-- No overlay needed for persistent sidebar -->
<label for="desktop-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<!-- Sidebar menu -->
<ul class="menu p-4 w-80 min-h-full bg-base-200">
<li><a>Dashboard</a></li>
<li><a>Settings</a></li>
<li><a>Profile</a></li>
</ul>
</div>
</div>
```
### How drawer-open Works
The `drawer-open` class forces the drawer to be **permanently open**:
```html
<div class="drawer drawer-open">
```
- Drawer is always visible
- Cannot be toggled closed
- `drawer-toggle` checkbox is ignored
- `drawer-overlay` is not shown
- Main content automatically shifts to accommodate sidebar width
### Responsive Usage
Use Tailwind breakpoint modifiers for responsive behavior:
```html
<!-- Open on large screens and above -->
<div class="drawer lg:drawer-open">
<!-- Open on medium screens and above -->
<div class="drawer md:drawer-open">
<!-- Open on extra-large screens and above -->
<div class="drawer xl:drawer-open">
```
## Combined Mobile + Desktop Pattern (Recommended)
This is the **most common pattern** for responsive applications: mobile overlay + desktop persistent.
### Complete Implementation
```html
<div class="drawer lg:drawer-open">
<!-- Checkbox for mobile toggle -->
<input id="app-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col">
<!-- Navbar with mobile menu button -->
<div class="navbar bg-base-100 lg:hidden">
<div class="flex-none">
<label for="app-drawer" class="btn btn-square btn-ghost">
<!-- Hamburger icon -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-5 h-5 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</label>
</div>
<div class="flex-1">
<a class="btn btn-ghost text-xl">My App</a>
</div>
</div>
<!-- Main content -->
<div class="flex-1 p-6">
<h1 class="text-3xl font-bold mb-4">Welcome</h1>
<p>This is the main content area.</p>
<p>On mobile (< lg): sidebar is hidden, hamburger menu visible</p>
<p>On desktop (≥ lg): sidebar is persistent, hamburger menu hidden</p>
</div>
</div>
<div class="drawer-side">
<!-- Overlay only shows on mobile -->
<label for="app-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<!-- Sidebar navigation -->
<aside class="bg-base-200 w-80 min-h-full">
<!-- Logo/Header area -->
<div class="p-4 font-bold text-xl border-b border-base-300">
My App Logo
</div>
<!-- Navigation menu -->
<ul class="menu p-4">
<li><a>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Dashboard
</a></li>
<li><a>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Documents
</a></li>
<li><a>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</a></li>
</ul>
</aside>
</div>
</div>
```
### Behavior Breakdown
#### On Mobile (< 1024px / < lg)
1. Sidebar is hidden by default
2. Hamburger button visible in navbar
3. Clicking hamburger opens sidebar as overlay
4. Clicking overlay or close button closes sidebar
5. Sidebar slides in from left with animation
#### On Desktop (≥ 1024px / ≥ lg)
1. `lg:drawer-open` keeps sidebar permanently visible
2. Hamburger button hidden via `lg:hidden`
3. Sidebar takes up fixed width (320px)
4. Main content area adjusts automatically
5. No overlay, no toggle needed
## Tailwind Breakpoints Reference
```css
/* Default (mobile-first) */
/* < 640px */
sm: /* ≥ 640px */
md: /* ≥ 768px */
lg: /* ≥ 1024px */ ← Common desktop breakpoint
xl: /* ≥ 1280px */
2xl: /* ≥ 1536px */
```
## Key Classes Summary
| Class | Purpose |
|-------|---------|
| `drawer` | Main container |
| `drawer-toggle` | Hidden checkbox for state control |
| `drawer-content` | Main content area |
| `drawer-side` | Sidebar container |
| `drawer-overlay` | Clickable overlay (closes drawer) |
| `drawer-open` | Forces drawer to stay open |
| `drawer-end` | Positions drawer on the right side |
| `lg:drawer-open` | Opens drawer on large screens only |
## Positioning Variants
### Left Side Drawer (Default)
```html
<div class="drawer">
<!-- Drawer appears on the left -->
</div>
```
### Right Side Drawer
```html
<div class="drawer drawer-end">
<!-- Drawer appears on the right -->
</div>
```
## Best Practices
### 1. Accessibility
- Always include `aria-label` on overlay: `<label for="drawer" aria-label="close sidebar" class="drawer-overlay"></label>`
- Use semantic HTML (`<nav>`, `<aside>`)
- Ensure keyboard navigation works (native checkbox provides this)
### 2. Responsive Design
- Use `lg:drawer-open` for desktop persistence
- Hide mobile toggle button on desktop: `lg:hidden`
- Adjust sidebar width for mobile if needed: `w-64 md:w-80`
### 3. Performance
- DaisyUI drawer is pure CSS (no JavaScript needed)
- Animations are handled by CSS transitions
- No performance overhead
### 4. Styling
- Use DaisyUI theme colors: `bg-base-200`, `text-base-content`
- Maintain consistent spacing: `p-4`, `gap-2`
- Use DaisyUI menu component for navigation: `<ul class="menu">`
### 5. Content Structure
```html
<div class="drawer-content flex flex-col">
<!-- Navbar (if needed) -->
<div class="navbar">...</div>
<!-- Main content with flex-1 to fill space -->
<div class="flex-1 p-6">
<!-- Your content -->
</div>
<!-- Footer (if needed) -->
<footer>...</footer>
</div>
```
## Common Patterns
### Pattern 1: Drawer with Close Button
```html
<div class="drawer-side">
<label for="drawer" class="drawer-overlay"></label>
<aside class="bg-base-200 w-80 min-h-full relative">
<!-- Close button (mobile only) -->
<label for="drawer" class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2 lg:hidden"></label>
<!-- Sidebar content -->
<ul class="menu p-4 pt-12">
<li><a>Item 1</a></li>
</ul>
</aside>
</div>
```
### Pattern 2: Drawer with User Profile
```html
<aside class="bg-base-200 w-80 min-h-full flex flex-col">
<!-- Logo -->
<div class="p-4 font-bold text-xl">My App</div>
<!-- Navigation (flex-1 to push footer down) -->
<ul class="menu flex-1 p-4">
<li><a>Dashboard</a></li>
<li><a>Settings</a></li>
</ul>
<!-- User profile footer -->
<div class="p-4 border-t border-base-300">
<div class="flex items-center gap-2">
<div class="avatar">
<div class="w-10 rounded-full">
<img src="/avatar.jpg" alt="User" />
</div>
</div>
<div>
<div class="font-semibold">John Doe</div>
<div class="text-sm opacity-70">john@example.com</div>
</div>
</div>
</div>
</aside>
```
### Pattern 3: Nested Menu with Submenu
```html
<ul class="menu p-4 w-80 min-h-full bg-base-200">
<li><a>Dashboard</a></li>
<!-- Submenu -->
<li>
<details>
<summary>Products</summary>
<ul>
<li><a>Electronics</a></li>
<li><a>Clothing</a></li>
<li><a>Books</a></li>
</ul>
</details>
</li>
<li><a>Settings</a></li>
</ul>
```
## Troubleshooting
### Issue: Drawer doesn't open on mobile
**Solution**: Check that:
1. Checkbox `id` matches label `for` attribute
2. Checkbox has class `drawer-toggle`
3. You're not using `drawer-open` on mobile breakpoints
### Issue: Drawer overlaps content on desktop
**Solution**:
- Remove `drawer-open` or use responsive variant `lg:drawer-open`
- Ensure you want overlay behavior, not persistent sidebar
### Issue: Overlay not clickable
**Solution**:
- Ensure overlay label has correct `for` attribute
- Check that overlay is not behind other elements (z-index)
### Issue: Content jumps when drawer opens
**Solution**:
- Add `flex flex-col` to `drawer-content`
- Ensure drawer-side width is fixed (e.g., `w-80`)
## Migration from Custom Solutions
If migrating from a custom sidebar implementation:
### Replace custom JavaScript
❌ Before:
```javascript
function toggleDrawer() {
document.getElementById('sidebar').classList.toggle('open');
}
```
✅ After:
```html
<input id="drawer" type="checkbox" class="drawer-toggle" />
<label for="drawer">Toggle</label>
```
### Replace custom CSS
❌ Before:
```css
.sidebar {
transform: translateX(-100%);
transition: transform 0.3s;
}
.sidebar.open {
transform: translateX(0);
}
```
✅ After:
```html
<div class="drawer">
<!-- DaisyUI handles all transitions -->
</div>
```
### Replace media query logic
❌ Before:
```css
@media (min-width: 1024px) {
.sidebar { display: block; }
.toggle-button { display: none; }
}
```
✅ After:
```html
<div class="drawer lg:drawer-open">
<label for="drawer" class="lg:hidden">Toggle</label>
</div>
```
## Summary
The DaisyUI drawer pattern provides:
**Zero JavaScript** - Pure CSS solution
**Accessible** - Built-in keyboard support via checkbox
**Responsive** - Easy mobile/desktop variants with Tailwind
**Themeable** - Uses DaisyUI theme colors
**Flexible** - Supports left/right positioning
**Standard** - No custom CSS needed
**Recommended approach**: Use `lg:drawer-open` for desktop with hidden mobile toggle for best responsive experience.

View file

@ -110,8 +110,8 @@ Control access to LiveView pages:
Three scope levels for permissions: Three scope levels for permissions:
- **:own** - Only records where `record.id == user.id` (for User resource) - **:own** - Only records where `record.id == user.id` (for User resource)
- **:linked** - Only records linked to user via relationships - **:linked** - Only records linked to user via relationships
- Member: `member.user_id == user.id` - Member: `id == user.member_id` (User.member_id → Member.id, inverse relationship)
- CustomFieldValue: `custom_field_value.member.user_id == user.id` - CustomFieldValue: `member_id == user.member_id` (traverses Member → User relationship)
- **:all** - All records, no filtering - **:all** - All records, no filtering
**6. Special Cases** **6. Special Cases**
@ -714,8 +714,8 @@ defmodule Mv.Authorization.Checks.HasPermission do
- **:all** - Authorizes without filtering (returns all records) - **:all** - Authorizes without filtering (returns all records)
- **:own** - Filters to records where record.id == actor.id - **:own** - Filters to records where record.id == actor.id
- **:linked** - Filters based on resource type: - **:linked** - Filters based on resource type:
- Member: member.user_id == actor.id - Member: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
- CustomFieldValue: custom_field_value.member.user_id == actor.id (traverses relationship!) - CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id → Member.id → User.member_id)
## Error Handling ## Error Handling
@ -799,12 +799,14 @@ defmodule Mv.Authorization.Checks.HasPermission do
defp apply_scope(:linked, actor, resource_name) do defp apply_scope(:linked, actor, resource_name) do
case resource_name do case resource_name do
"Member" -> "Member" ->
# Member.user_id == actor.id (direct relationship) # User.member_id → Member.id (inverse relationship)
{:filter, expr(user_id == ^actor.id)} # Filter: member.id == actor.member_id
{:filter, expr(id == ^actor.member_id)}
"CustomFieldValue" -> "CustomFieldValue" ->
# CustomFieldValue.member.user_id == actor.id (traverse through member!) # CustomFieldValue.member_id → Member.id → User.member_id
{:filter, expr(member.user_id == ^actor.id)} # Filter: custom_field_value.member_id == actor.member_id
{:filter, expr(member_id == ^actor.member_id)}
_ -> _ ->
# Fallback for other resources: try direct user_id # Fallback for other resources: try direct user_id
@ -918,7 +920,7 @@ end
**Location:** `lib/mv/membership/member.ex` **Location:** `lib/mv/membership/member.ex`
**Special Case:** Users can always access their linked member (where `member.user_id == user.id`). **Special Case:** Users can always READ their linked member (where `id == user.member_id`).
```elixir ```elixir
defmodule Mv.Membership.Member do defmodule Mv.Membership.Member do
@ -978,10 +980,10 @@ defmodule Mv.Membership.CustomFieldValue do
policies do policies do
# SPECIAL CASE: Users can access custom field values of their linked member # SPECIAL CASE: Users can access custom field values of their linked member
# Note: This traverses the member relationship! # Note: This uses member_id relationship (CustomFieldValue.member_id → Member.id → User.member_id)
policy action_type([:read, :update]) do policy action_type([:read, :update]) do
description "Users can access custom field values of their linked member" description "Users can access custom field values of their linked member"
authorize_if expr(member.user_id == ^actor(:id)) authorize_if expr(member_id == ^actor(:member_id))
end end
# GENERAL: Check permissions from role # GENERAL: Check permissions from role

View file

@ -294,7 +294,9 @@ Each Permission Set contains:
**:own** - Only records where id == actor.id **:own** - Only records where id == actor.id
- Example: User can read their own User record - Example: User can read their own User record
**:linked** - Only records where user_id == actor.id **:linked** - Only records linked to actor via relationships
- Member: `id == actor.member_id` (User.member_id → Member.id, inverse relationship)
- CustomFieldValue: `member_id == actor.member_id` (traverses Member → User relationship)
- Example: User can read Member linked to their account - Example: User can read Member linked to their account
**:all** - All records without restriction **:all** - All records without restriction

View file

@ -0,0 +1,747 @@
# Sidebar Analysis - Current State
**Erstellt:** 2025-12-16
**Status:** Analyse für Neuimplementierung
**Autor:** Cursor AI Assistant
---
## Executive Summary
Die aktuelle Sidebar-Implementierung verwendet **nicht existierende Custom-CSS-Variants** (`is-drawer-close:` und `is-drawer-open:`), was zu einer defekten Implementierung führt. Die Sidebar ist strukturell basierend auf DaisyUI's Drawer-Komponente, aber die responsive und state-basierte Funktionalität ist nicht funktionsfähig.
**Kritisches Problem:** Die im Code verwendeten Variants `is-drawer-close:*` und `is-drawer-open:*` sind **nicht in Tailwind konfiguriert**, was bedeutet, dass diese Klassen beim Build ignoriert werden.
---
## 1. Dateien-Übersicht
### 1.1 Hauptdateien
| Datei | Zweck | Zeilen | Status |
|-------|-------|--------|--------|
| `lib/mv_web/components/layouts/sidebar.ex` | Sidebar-Komponente (Elixir) | 198 | ⚠️ Verwendet nicht existierende Variants |
| `lib/mv_web/components/layouts/navbar.ex` | Navbar mit Sidebar-Toggle | 48 | ✅ Funktional |
| `lib/mv_web/components/layouts.ex` | Layout-Wrapper mit Drawer | 121 | ✅ Funktional |
| `assets/js/app.js` | JavaScript für Sidebar-Interaktivität | 272 | ✅ Umfangreiche Accessibility-Logik |
| `assets/css/app.css` | CSS-Konfiguration | 103 | ⚠️ Keine Drawer-Variants definiert |
| `assets/tailwind.config.js` | Tailwind-Konfiguration | 75 | ⚠️ Keine Drawer-Variants definiert |
### 1.2 Verwandte Dateien
- `lib/mv_web/components/layouts/root.html.heex` - Root-Layout (minimal, keine Sidebar-Logik)
- `priv/static/images/logo.svg` - Logo (wird vermutlich für Sidebar benötigt)
---
## 2. Aktuelle Struktur
### 2.1 HTML-Struktur (DaisyUI Drawer Pattern)
```html
<!-- In layouts.ex -->
<div class="drawer">
<input id="main-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content">
<!-- Navbar mit Toggle-Button -->
<navbar with sidebar-toggle button />
<!-- Hauptinhalt -->
<main>...</main>
</div>
<!-- Sidebar -->
<div class="drawer-side">
<button class="drawer-overlay" onclick="close drawer"></button>
<nav id="main-sidebar">
<!-- Navigation Items -->
</nav>
</div>
</div>
```
**Bewertung:** ✅ Korrekte DaisyUI Drawer-Struktur
### 2.2 Sidebar-Komponente (`sidebar.ex`)
**Struktur:**
```elixir
defmodule MvWeb.Layouts.Sidebar do
attr :current_user, :map
attr :club_name, :string
def sidebar(assigns) do
# Rendert Sidebar mit Navigation, Locale-Selector, Theme-Toggle, User-Menu
end
end
```
**Hauptelemente:**
1. **Drawer Overlay** - Button zum Schließen (Mobile)
2. **Navigation Container** (`<nav id="main-sidebar">`)
3. **Menü-Items** - Members, Users, Contributions (nested), Settings
4. **Footer-Bereich** - Locale-Selector, Theme-Toggle, User-Menu
---
## 3. Custom CSS Variants - KRITISCHES PROBLEM
### 3.1 Verwendete Variants im Code
Die Sidebar verwendet folgende Custom-Variants **extensiv**:
```elixir
# Beispiele aus sidebar.ex
"is-drawer-close:overflow-visible"
"is-drawer-close:w-14 is-drawer-open:w-64"
"is-drawer-close:hidden"
"is-drawer-close:tooltip is-drawer-close:tooltip-right"
"is-drawer-close:w-auto"
"is-drawer-close:justify-center"
"is-drawer-close:dropdown-end"
```
**Gefundene Verwendungen:**
- `is-drawer-close:` - 13 Instanzen in sidebar.ex
- `is-drawer-open:` - 1 Instanz in sidebar.ex
### 3.2 Definition der Variants
**❌ NICHT GEFUNDEN in:**
- `assets/css/app.css` - Enthält nur `phx-*-loading` Variants
- `assets/tailwind.config.js` - Enthält nur `phx-*-loading` Variants
**Fazit:** Diese Variants existieren **nicht** und werden beim Tailwind-Build **ignoriert**!
### 3.3 Vorhandene Variants
Nur folgende Custom-Variants sind tatsächlich definiert:
```css
/* In app.css (Tailwind CSS 4.x Syntax) */
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
```
```javascript
/* In tailwind.config.js (Tailwind 3.x Kompatibilität) */
plugin(({addVariant}) => addVariant("phx-click-loading", [...])),
plugin(({addVariant}) => addVariant("phx-submit-loading", [...])),
plugin(({addVariant}) => addVariant("phx-change-loading", [...])),
```
---
## 4. JavaScript-Implementierung
### 4.1 Übersicht
Die JavaScript-Implementierung ist **sehr umfangreich** und fokussiert auf Accessibility:
**Datei:** `assets/js/app.js` (Zeilen 106-270)
**Hauptfunktionalitäten:**
1. ✅ Tabindex-Management für fokussierbare Elemente
2. ✅ ARIA-Attribut-Management (`aria-expanded`)
3. ✅ Keyboard-Navigation (Enter, Space, Escape)
4. ✅ Focus-Management beim Öffnen/Schließen
5. ✅ Dropdown-Integration
### 4.2 Wichtige JavaScript-Funktionen
#### 4.2.1 Tabindex-Management
```javascript
const updateSidebarTabIndex = (isOpen) => {
const allFocusableElements = sidebar.querySelectorAll(
'a[href], button, select, input:not([type="hidden"]), [tabindex]'
)
allFocusableElements.forEach(el => {
if (isOpen) {
// Make focusable when open
el.removeAttribute('tabindex')
} else {
// Remove from tab order when closed
el.setAttribute('tabindex', '-1')
}
})
}
```
**Zweck:** Verhindert, dass Nutzer mit Tab zu unsichtbaren Sidebar-Elementen springen können.
#### 4.2.2 ARIA-Expanded Management
```javascript
const updateAriaExpanded = () => {
const isOpen = drawerToggle.checked
sidebarToggle.setAttribute("aria-expanded", isOpen.toString())
}
```
**Zweck:** Informiert Screen-Reader über den Sidebar-Status.
#### 4.2.3 Focus-Management
```javascript
const getFirstFocusableElement = () => {
// Priority: navigation link > other links > other focusable
const firstNavLink = sidebar.querySelector('a[href][role="menuitem"]')
// ... fallback logic
}
// On open: focus first element
// On close: focus toggle button
```
**Zweck:** Logische Fokus-Reihenfolge für Keyboard-Navigation.
#### 4.2.4 Keyboard-Shortcuts
```javascript
// ESC to close
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && drawerToggle.checked) {
drawerToggle.checked = false
sidebarToggle.focus()
}
})
// Enter/Space on toggle button
sidebarToggle.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
// Toggle drawer and manage focus
}
})
```
### 4.3 LiveView Hooks
**Definierte Hooks:**
```javascript
Hooks.CopyToClipboard = { ... } // Clipboard-Funktionalität
Hooks.ComboBox = { ... } // Dropdown-Prävention bei Enter
```
**Sidebar-spezifisch:** Keine Hooks, nur native DOM-Events.
---
## 5. DaisyUI Dependencies
### 5.1 Verwendete DaisyUI-Komponenten
| Komponente | Verwendung | Klassen |
|------------|-----------|---------|
| **Drawer** | Basis-Layout | `drawer`, `drawer-toggle`, `drawer-side`, `drawer-content`, `drawer-overlay` |
| **Menu** | Navigation | `menu`, `menu-title`, `w-64` |
| **Button** | Toggle, User-Menu | `btn`, `btn-ghost`, `btn-square`, `btn-circle` |
| **Avatar** | User-Menu | `avatar`, `avatar-placeholder` |
| **Dropdown** | User-Menu | `dropdown`, `dropdown-top`, `dropdown-end`, `dropdown-content` |
| **Tooltip** | Icon-Tooltips | `tooltip`, `tooltip-right` (via `data-tip`) |
| **Select** | Locale-Selector | `select`, `select-sm` |
| **Toggle** | Theme-Switch | `toggle`, `theme-controller` |
### 5.2 Standard Tailwind-Klassen
**Layout:**
- `flex`, `flex-col`, `items-start`, `justify-center`
- `gap-2`, `gap-4`, `p-4`, `mt-auto`, `w-full`, `w-64`, `min-h-full`
**Sizing:**
- `size-4`, `size-5`, `w-12`, `w-52`
**Colors:**
- `bg-base-100`, `bg-base-200`, `text-neutral-content`
**Typography:**
- `text-lg`, `text-sm`, `font-bold`
**Accessibility:**
- `sr-only`, `focus:outline-none`, `focus:ring-2`, `focus:ring-primary`
---
## 6. Toggle-Button (Navbar)
### 6.1 Implementierung
**Datei:** `lib/mv_web/components/layouts/navbar.ex`
```elixir
<button
type="button"
onclick="document.getElementById('main-drawer').checked = !document.getElementById('main-drawer').checked"
aria-label={gettext("Toggle navigation menu")}
aria-expanded="false"
aria-controls="main-sidebar"
id="sidebar-toggle"
class="mr-2 btn btn-square btn-ghost"
>
<svg><!-- Layout-Panel-Left Icon --></svg>
</button>
```
**Funktionalität:**
- ✅ Togglet Drawer-Checkbox
- ✅ ARIA-Labels vorhanden
- ✅ Keyboard-accessible
- ⚠️ `aria-expanded` wird durch JavaScript aktualisiert
**Icon:** Custom SVG (Layout-Panel-Left mit Chevron-Right)
---
## 7. Responsive Verhalten
### 7.1 Aktuelles Konzept (nicht funktional)
**Versuchte Implementierung:**
- **Desktop (collapsed):** Sidebar mit 14px Breite (`is-drawer-close:w-14`)
- **Desktop (expanded):** Sidebar mit 64px Breite (`is-drawer-open:w-64`)
- **Mobile:** Overlay-Drawer (DaisyUI Standard)
### 7.2 Problem
Da die `is-drawer-*` Variants nicht existieren, gibt es **kein responsives Verhalten**:
- Die Sidebar hat immer eine feste Breite von `w-64`
- Die conditional hiding (`:hidden`, etc.) funktioniert nicht
- Tooltips werden nicht conditional angezeigt
---
## 8. Accessibility-Features
### 8.1 Implementierte Features
| Feature | Status | Implementierung |
|---------|--------|-----------------|
| **ARIA Labels** | ✅ | Alle interaktiven Elemente haben Labels |
| **ARIA Roles** | ✅ | `menubar`, `menuitem`, `menu`, `button` |
| **ARIA Expanded** | ✅ | Wird durch JS dynamisch gesetzt |
| **ARIA Controls** | ✅ | Toggle → Sidebar verknüpft |
| **Keyboard Navigation** | ✅ | Enter, Space, Escape, Tab |
| **Focus Management** | ✅ | Logische Focus-Reihenfolge |
| **Tabindex Management** | ✅ | Verhindert Focus auf hidden Elements |
| **Screen Reader Only** | ✅ | `.sr-only` für visuelle Labels |
| **Focus Indicators** | ✅ | `focus:ring-2 focus:ring-primary` |
| **Skip Links** | ❌ | Nicht vorhanden |
### 8.2 Accessibility-Score
**Geschätzt:** 90/100 (WCAG 2.1 Level AA konform)
**Verbesserungspotenzial:**
- Skip-Link zur Hauptnavigation hinzufügen
- High-Contrast-Mode testen
---
## 9. Menü-Struktur
### 9.1 Navigation Items
```
📋 Main Menu
├── 👥 Members (/members)
├── 👤 Users (/users)
├── 💰 Contributions (collapsed submenu)
│ ├── Plans (/contribution_types)
│ └── Settings (/contribution_settings)
└── ⚙️ Settings (/settings)
🔽 Footer Area (logged in only)
├── 🌐 Locale Selector (DE/EN)
├── 🌓 Theme Toggle (Light/Dark)
└── 👤 User Menu (Dropdown)
├── Profile (/users/:id)
└── Logout (/sign-out)
```
### 9.2 Conditional Rendering
**Nicht eingeloggt:**
- Sidebar ist leer (nur Struktur)
- Keine Menü-Items
**Eingeloggt:**
- Vollständige Navigation
- Footer-Bereich mit User-Menu
### 9.3 Nested Menu (Contributions)
**Problem:** Das Contributions-Submenu ist **immer versteckt** im collapsed State:
```elixir
<li class="is-drawer-close:hidden" role="none">
<h2 class="flex items-center gap-2 menu-title">
<.icon name="hero-currency-dollar" />
{gettext("Contributions")}
</h2>
<ul role="menu">
<li class="is-drawer-close:hidden">...</li>
<li class="is-drawer-close:hidden">...</li>
</ul>
</li>
```
Da `:hidden` nicht funktioniert, wird das Submenu immer angezeigt.
---
## 10. Theme-Funktionalität
### 10.1 Theme-Toggle
```elixir
<input
type="checkbox"
value="dark"
class="toggle theme-controller"
aria-label={gettext("Toggle dark mode")}
/>
```
**Funktionalität:**
- ✅ DaisyUI `theme-controller` - automatische Theme-Umschaltung
- ✅ Persistence durch `localStorage` (siehe root.html.heex Script)
- ✅ Icon-Wechsel (Sun ↔ Moon)
### 10.2 Definierte Themes
**Datei:** `assets/css/app.css`
1. **Light Theme** (default)
- Base: `oklch(98% 0 0)`
- Primary: `oklch(70% 0.213 47.604)` (Orange/Phoenix-inspiriert)
2. **Dark Theme**
- Base: `oklch(30.33% 0.016 252.42)`
- Primary: `oklch(58% 0.233 277.117)` (Purple/Elixir-inspiriert)
---
## 11. Locale-Funktionalität
### 11.1 Locale-Selector
```elixir
<form method="post" action="/set_locale">
<select
id="locale-select-sidebar"
name="locale"
onchange="this.form.submit()"
class="select select-sm w-full is-drawer-close:w-auto"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</form>
```
**Funktionalität:**
- ✅ POST zu `/set_locale` Endpoint
- ✅ CSRF-Token included
- ✅ Auto-Submit on change
- ✅ Accessible Label (`.sr-only`)
---
## 12. Probleme und Defekte
### 12.1 Kritische Probleme
| Problem | Schweregrad | Details |
|---------|-------------|---------|
| **Nicht existierende CSS-Variants** | 🔴 Kritisch | `is-drawer-close:*` und `is-drawer-open:*` sind nicht definiert |
| **Keine responsive Funktionalität** | 🔴 Kritisch | Sidebar verhält sich nicht wie geplant |
| **Conditional Styles funktionieren nicht** | 🔴 Kritisch | Hidden/Tooltip/Width-Changes werden ignoriert |
### 12.2 Mittlere Probleme
| Problem | Schweregrad | Details |
|---------|-------------|---------|
| **Kein Logo** | 🟡 Mittel | Logo-Element fehlt komplett in der Sidebar |
| **Submenu immer sichtbar** | 🟡 Mittel | Contributions-Submenu sollte in collapsed State versteckt sein |
| **Toggle-Icon statisch** | 🟡 Mittel | Icon ändert sich nicht zwischen expanded/collapsed |
### 12.3 Kleinere Probleme
| Problem | Schweregrad | Details |
|---------|-------------|---------|
| **Code-Redundanz** | 🟢 Klein | Variants in beiden Tailwind-Configs (3.x und 4.x) |
| **Inline-onclick Handler** | 🟢 Klein | Sollten durch JS-Events ersetzt werden |
| **Keine Skip-Links** | 🟢 Klein | Accessibility-Verbesserung |
---
## 13. Abhängigkeiten
### 13.1 Externe Abhängigkeiten
| Dependency | Version | Verwendung |
|------------|---------|------------|
| **DaisyUI** | Latest (vendor) | Drawer, Menu, Button, etc. |
| **Tailwind CSS** | 4.0.9 | Utility-Klassen |
| **Heroicons** | v2.2.0 | Icons in Navigation |
| **Phoenix LiveView** | ~> 1.1.0 | Backend-Integration |
### 13.2 Interne Abhängigkeiten
| Modul | Verwendung |
|-------|-----------|
| `MvWeb.Gettext` | Internationalisierung |
| `Mv.Membership.get_settings()` | Club-Name abrufen |
| `MvWeb.CoreComponents` | Icons, Links |
---
## 14. Code-Qualität
### 14.1 Positives
- ✅ **Sehr gute Accessibility-Implementierung**
- ✅ **Saubere Modulstruktur** (Separation of Concerns)
- ✅ **Gute Dokumentation** (Moduledocs, Attribute docs)
- ✅ **Internationalisierung** vollständig implementiert
- ✅ **ARIA-Best-Practices** befolgt
- ✅ **Keyboard-Navigation** umfassend
### 14.2 Verbesserungsbedarf
- ❌ **Broken CSS-Variants** (Hauptproblem)
- ❌ **Fehlende Tests** (keine Component-Tests gefunden)
- ⚠️ **Inline-JavaScript** in onclick-Attributen
- ⚠️ **Magic-IDs** (`main-drawer`, `sidebar-toggle`) hardcoded
- ⚠️ **Komplexe JavaScript-Logik** ohne Dokumentation
---
## 15. Empfehlungen für Neuimplementierung
### 15.1 Sofort-Maßnahmen
1. **CSS-Variants entfernen**
- Alle `is-drawer-close:*` und `is-drawer-open:*` entfernen
- Durch Standard-Tailwind oder DaisyUI-Mechanismen ersetzen
2. **Logo hinzufügen**
- Logo-Element als erstes Element in Sidebar
- Konsistente Größe (32px / size-8)
3. **Toggle-Icon implementieren**
- Icon-Swap zwischen Chevron-Left und Chevron-Right
- Nur auf Desktop sichtbar
### 15.2 Architektur-Entscheidungen
1. **Responsive Strategie:**
- **Mobile:** Standard DaisyUI Drawer (Overlay)
- **Desktop:** Persistent Sidebar mit fester Breite
- **Kein collapsing auf Desktop** (einfacher, wartbarer)
2. **State-Management:**
- Drawer-Checkbox für Mobile
- Keine zusätzlichen Custom-Variants
- Standard DaisyUI-Mechanismen verwenden
3. **JavaScript-Refactoring:**
- Hooks statt inline-onclick
- Dokumentierte Funktionen
- Unit-Tests für kritische Logik
### 15.3 Prioritäten
**High Priority:**
1. CSS-Variants-Problem lösen
2. Logo implementieren
3. Basic responsive Funktionalität
**Medium Priority:**
4. Toggle-Icon implementieren
5. Tests schreiben
6. JavaScript refactoren
**Low Priority:**
7. Skip-Links hinzufügen
8. Code-Optimierung
9. Performance-Tuning
---
## 16. Checkliste für Neuimplementierung
### 16.1 Vorbereitung
- [ ] Alle `is-drawer-*` Klassen aus Code entfernen
- [ ] Keine Custom-Variants in CSS/Tailwind definieren
- [ ] DaisyUI-Dokumentation für Drawer studieren
### 16.2 Implementation
- [ ] Logo-Element hinzufügen (size-8, persistent)
- [ ] Toggle-Button mit Icon-Swap (nur Desktop)
- [ ] Mobile: Overlay-Drawer (DaisyUI Standard)
- [ ] Desktop: Persistent Sidebar (w-64)
- [ ] Menü-Items mit korrekten Klassen
- [ ] Submenu-Handling (nested `<ul>`)
### 16.3 Funktionalität
- [ ] Toggle-Funktionalität auf Mobile
- [ ] Accessibility: ARIA, Focus, Keyboard
- [ ] Theme-Toggle funktional
- [ ] Locale-Selector funktional
- [ ] User-Menu-Dropdown funktional
### 16.4 Testing
- [ ] Component-Tests schreiben
- [ ] Accessibility-Tests (axe-core)
- [ ] Keyboard-Navigation testen
- [ ] Screen-Reader testen
- [ ] Responsive Breakpoints testen
### 16.5 Dokumentation
- [ ] Code-Kommentare aktualisieren
- [ ] Component-Docs schreiben
- [ ] README aktualisieren
---
## 17. Technische Details
### 17.1 CSS-Selektoren
**Verwendete IDs:**
- `#main-drawer` - Drawer-Toggle-Checkbox
- `#main-sidebar` - Sidebar-Navigation-Container
- `#sidebar-toggle` - Toggle-Button in Navbar
- `#locale-select-sidebar` - Locale-Dropdown
**Verwendete Klassen:**
- `.drawer-side` - DaisyUI Sidebar-Container
- `.drawer-overlay` - DaisyUI Overlay-Button
- `.drawer-content` - DaisyUI Content-Container
- `.menu` - DaisyUI Menu-Container
- `.is-drawer-close:*` - ❌ NICHT DEFINIERT
- `.is-drawer-open:*` - ❌ NICHT DEFINIERT
### 17.2 Event-Handler
**JavaScript:**
```javascript
drawerToggle.addEventListener("change", ...)
sidebarToggle.addEventListener("click", ...)
sidebarToggle.addEventListener("keydown", ...)
document.addEventListener("keydown", ...) // ESC handler
```
**Inline (zu migrieren):**
```elixir
onclick="document.getElementById('main-drawer').checked = false"
onclick="document.getElementById('main-drawer').checked = !..."
onchange="this.form.submit()"
```
---
## 18. Metriken
### 18.1 Code-Metriken
| Metrik | Wert |
|--------|------|
| **Zeilen Code (Sidebar)** | 198 |
| **Zeilen JavaScript** | 165 (Sidebar-spezifisch) |
| **Zeilen CSS** | 0 (nur Tailwind-Klassen) |
| **Anzahl Komponenten** | 1 (Sidebar) + 1 (Navbar) |
| **Anzahl Menü-Items** | 6 (inkl. Submenu) |
| **Anzahl Footer-Controls** | 3 (Locale, Theme, User) |
### 18.2 Abhängigkeits-Metriken
| Kategorie | Anzahl |
|-----------|--------|
| **DaisyUI-Komponenten** | 7 |
| **Tailwind-Utility-Klassen** | ~50 |
| **Custom-Variants (broken)** | 2 (`is-drawer-close`, `is-drawer-open`) |
| **JavaScript-Event-Listener** | 6 |
| **ARIA-Attribute** | 12 |
---
## 19. Zusammenfassung
### 19.1 Was funktioniert
✅ **Sehr gute Grundlage:**
- DaisyUI Drawer-Pattern korrekt implementiert
- Exzellente Accessibility (ARIA, Keyboard, Focus)
- Saubere Modulstruktur
- Internationalisierung
- Theme-Switching
- JavaScript-Logik ist robust
### 19.2 Was nicht funktioniert
❌ **Kritische Defekte:**
- CSS-Variants existieren nicht → keine responsive Funktionalität
- Kein Logo
- Kein Toggle-Icon-Swap
- Submenu-Handling defekt
### 19.3 Nächste Schritte
1. **CSS-Variants entfernen** (alle `is-drawer-*` Klassen)
2. **Standard DaisyUI-Pattern verwenden** (ohne Custom-Variants)
3. **Logo hinzufügen** (persistent, size-8)
4. **Simplify:** Mobile = Overlay, Desktop = Persistent (keine collapsed State)
5. **Tests schreiben** (Component + Accessibility)
---
## 20. Anhang
### 20.1 Verwendete CSS-Klassen (alphabetisch)
```
avatar, avatar-placeholder, bg-base-100, bg-base-200, bg-neutral,
btn, btn-circle, btn-ghost, btn-square, cursor-pointer, drawer,
drawer-content, drawer-overlay, drawer-side, drawer-toggle, dropdown,
dropdown-content, dropdown-end, dropdown-top, flex, flex-col,
focus:outline-none, focus:ring-2, focus:ring-primary,
focus-within:outline-none, focus-within:ring-2, gap-2, gap-4,
is-drawer-close:*, is-drawer-open:*, items-center, items-start,
mb-2, menu, menu-sm, menu-title, min-h-full, mr-2, mt-3, mt-auto,
p-2, p-4, rounded-box, rounded-full, select, select-sm, shadow,
shadow-sm, size-4, size-5, sr-only, text-lg, text-neutral-content,
text-sm, theme-controller, toggle, tooltip, tooltip-right, w-12,
w-52, w-64, w-full, z-1
```
### 20.2 Verwendete ARIA-Attribute
```
aria-busy, aria-controls, aria-describedby, aria-expanded,
aria-haspopup, aria-hidden, aria-label, aria-labelledby,
aria-live, role="alert", role="button", role="menu",
role="menubar", role="menuitem", role="none", role="status"
```
### 20.3 Relevante Links
- [DaisyUI Drawer Docs](https://daisyui.com/components/drawer/)
- [Tailwind CSS Custom Variants](https://tailwindcss.com/docs/adding-custom-styles#adding-custom-variants)
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
- [Phoenix LiveView Docs](https://hexdocs.pm/phoenix_live_view/)
---
**Ende des Berichts**

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,233 @@
# Analyse der fehlschlagenden Tests
## Übersicht
**Gesamtanzahl fehlschlagender Tests:** 5
- **show_test.exs:** 1 Fehler
- **sidebar_test.exs:** 4 Fehler
---
## Kategorisierung
### Kategorie 1: Test-Assertions passen nicht zur Implementierung (4 Tests)
Diese Tests erwarten bestimmte Werte/Attribute, die in der aktuellen Implementierung anders sind oder fehlen.
### Kategorie 2: Datenbank-Isolation Problem (1 Test)
Ein Test schlägt fehl, weil die Datenbank nicht korrekt isoliert ist.
---
## Detaillierte Analyse
### 1. `show_test.exs` - Custom Fields Sichtbarkeit
**Test:** `does not display Custom Fields section when no custom fields exist` (Zeile 112)
**Problem:**
- Der Test erwartet, dass die "Custom Fields" Sektion NICHT angezeigt wird, wenn keine Custom Fields existieren
- Die Sektion wird aber angezeigt, weil in der Datenbank noch Custom Fields von anderen Tests vorhanden sind
**Ursache:**
- Die LiveView lädt alle Custom Fields aus der Datenbank (Zeile 238-242 in `show.ex`)
- Die Test-Datenbank wird nicht zwischen Tests geleert
- Da `async: false` verwendet wird, sollten die Tests sequenziell laufen, aber Custom Fields bleiben in der Datenbank
**Kategorie:** Datenbank-Isolation Problem
---
### 2. `sidebar_test.exs` - Settings Link
**Test:** `T3.1: renders flat menu items with icons and labels` (Zeile 174)
**Problem:**
- Test erwartet `href="#"` für Settings
- Tatsächlicher Wert: `href="/settings"`
**Ursache:**
- Die Implementierung verwendet einen echten Link `~p"/settings"` (Zeile 100 in `sidebar.ex`)
- Der Test erwartet einen Placeholder-Link `href="#"`
**Kategorie:** Test-Assertion passt nicht zur Implementierung
---
### 3. `sidebar_test.exs` - Drawer Overlay CSS-Klasse
**Test:** `drawer overlay is present` (Zeile 747)
**Problem:**
- Test sucht nach exakt `class="drawer-overlay"`
- Tatsächlicher Wert: `class="drawer-overlay lg:hidden focus:outline-none focus:ring-2 focus:ring-primary"`
**Ursache:**
- Der Test verwendet eine exakte String-Suche (`~s(class="drawer-overlay")`)
- Die Implementierung hat mehrere CSS-Klassen
**Kategorie:** Test-Assertion passt nicht zur Implementierung
---
### 4. `sidebar_test.exs` - Toggle Button ARIA-Attribut
**Test:** `T5.2: toggle button has correct ARIA attributes` (Zeile 324)
**Problem:**
- Test erwartet `aria-controls="main-sidebar"` am Toggle-Button
- Das Attribut fehlt in der Implementierung (Zeile 45-65 in `sidebar.ex`)
**Ursache:**
- Das `aria-controls` Attribut wurde nicht in der Implementierung hinzugefügt
- Der Test erwartet es für bessere Accessibility
**Kategorie:** Test-Assertion passt nicht zur Implementierung (Accessibility-Feature fehlt)
---
### 5. `sidebar_test.exs` - Contribution Settings Link
**Test:** `sidebar structure is complete with all sections` (Zeile 501)
**Problem:**
- Test erwartet Link `/contribution_settings`
- Tatsächlicher Link: `/membership_fee_settings`
**Ursache:**
- Der Test hat eine veraltete/inkorrekte Erwartung
- Die Implementierung verwendet `/membership_fee_settings` (Zeile 96 in `sidebar.ex`)
**Kategorie:** Test-Assertion passt nicht zur Implementierung (veralteter Test)
---
## Lösungsvorschläge
### Lösung 1: `show_test.exs` - Custom Fields Sichtbarkeit
**Option A: Test-Datenbank bereinigen (Empfohlen)**
- Im `setup` Block alle Custom Fields löschen, bevor der Test läuft
- Oder: Explizit prüfen, dass keine Custom Fields existieren
**Option B: Test anpassen**
- Den Test so anpassen, dass er explizit alle Custom Fields löscht
- Oder: Die LiveView-Logik ändern, um nur Custom Fields zu laden, die tatsächlich existieren
**Empfehlung:** Option A - Im Test-Setup alle Custom Fields löschen
```elixir
setup do
# Clean up any existing custom fields
Mv.Membership.CustomField
|> Ash.read!()
|> Enum.each(&Ash.destroy!/1)
# Create test member
{:ok, member} = ...
%{member: member}
end
```
---
### Lösung 2: `sidebar_test.exs` - Settings Link
**Option A: Test anpassen (Empfohlen)**
- Test ändern, um `href="/settings"` zu erwarten statt `href="#"`
**Option B: Implementierung ändern**
- Settings-Link zu `href="#"` ändern (nicht empfohlen, da es ein echter Link sein sollte)
**Empfehlung:** Option A - Test anpassen
```elixir
# Zeile 190 ändern von:
assert html =~ ~s(href="#")
# zu:
assert html =~ ~s(href="/settings")
```
---
### Lösung 3: `sidebar_test.exs` - Drawer Overlay CSS-Klasse
**Option A: Test anpassen (Empfohlen)**
- Test ändern, um nach der Klasse in der Klasse-Liste zu suchen (mit `has_class?` Helper)
**Option B: Regex verwenden**
- Regex verwenden, um die Klasse zu finden
**Empfehlung:** Option A - Test anpassen
```elixir
# Zeile 752 ändern von:
assert html =~ ~s(class="drawer-overlay")
# zu:
assert has_class?(html, "drawer-overlay")
```
---
### Lösung 4: `sidebar_test.exs` - Toggle Button ARIA-Attribut
**Option A: Implementierung anpassen (Empfohlen)**
- `aria-controls="main-sidebar"` zum Toggle-Button hinzufügen
**Option B: Test anpassen**
- Test entfernen oder als optional markieren (nicht empfohlen für Accessibility)
**Empfehlung:** Option A - Implementierung anpassen
```elixir
# In sidebar.ex Zeile 45-52, aria-controls hinzufügen:
<button
type="button"
id="sidebar-toggle"
class="hidden lg:flex ml-auto btn btn-ghost btn-sm btn-square"
aria-label={gettext("Toggle sidebar")}
aria-controls="main-sidebar"
aria-expanded="true"
onclick="toggleSidebar()"
>
```
---
### Lösung 5: `sidebar_test.exs` - Contribution Settings Link
**Option A: Test anpassen (Empfohlen)**
- Test ändern, um `/membership_fee_settings` statt `/contribution_settings` zu erwarten
**Option B: Link hinzufügen**
- Einen neuen Link `/contribution_settings` hinzufügen (nicht empfohlen, da redundant)
**Empfehlung:** Option A - Test anpassen
```elixir
# Zeile 519 ändern von:
"/contribution_settings",
# zu:
# Entfernen oder durch "/membership_fee_settings" ersetzen
# (da "/membership_fee_settings" bereits in Zeile 518 vorhanden ist)
```
---
## Zusammenfassung der empfohlenen Änderungen
1. **show_test.exs:** Custom Fields im Setup löschen
2. **sidebar_test.exs (T3.1):** Settings-Link Assertion anpassen
3. **sidebar_test.exs (drawer overlay):** CSS-Klasse-Suche mit Helper-Funktion
4. **sidebar_test.exs (T5.2):** `aria-controls` Attribut zur Implementierung hinzufügen
5. **sidebar_test.exs (edge cases):** Falschen Link aus erwarteter Liste entfernen
---
## Priorisierung
1. **Hoch:** Lösung 1 (show_test.exs) - Datenbank-Isolation ist wichtig
2. **Mittel:** Lösung 4 (ARIA-Attribut) - Accessibility-Verbesserung
3. **Niedrig:** Lösungen 2, 3, 5 - Einfache Test-Anpassungen

1576
docs/umsetzung-sidebar.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -34,12 +34,16 @@ defmodule Mv.Membership.Member do
""" """
use Ash.Resource, use Ash.Resource,
domain: Mv.Membership, domain: Mv.Membership,
data_layer: AshPostgres.DataLayer data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
require Ash.Query require Ash.Query
import Ash.Expr import Ash.Expr
alias Mv.Helpers
require Logger require Logger
alias Mv.Membership.Helpers.VisibilityConfig
# Module constants # Module constants
@member_search_limit 10 @member_search_limit 10
@ -116,11 +120,12 @@ defmodule Mv.Membership.Member do
# Only runs if membership_fee_type_id is set # Only runs if membership_fee_type_id is set
# Note: Cycle generation runs asynchronously to not block the action, # Note: Cycle generation runs asynchronously to not block the action,
# but in test environment it runs synchronously for DB sandbox compatibility # but in test environment it runs synchronously for DB sandbox compatibility
change after_transaction(fn _changeset, result, _context -> change after_transaction(fn changeset, result, _context ->
case result do case result do
{:ok, member} -> {:ok, member} ->
if member.membership_fee_type_id && member.join_date do if member.membership_fee_type_id && member.join_date do
handle_cycle_generation(member) actor = Map.get(changeset.context, :actor)
handle_cycle_generation(member, actor: actor)
end end
{:error, _} -> {:error, _} ->
@ -191,7 +196,9 @@ defmodule Mv.Membership.Member do
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id) Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
if fee_type_changed && member.membership_fee_type_id && member.join_date do if fee_type_changed && member.membership_fee_type_id && member.join_date do
case regenerate_cycles_on_type_change(member) do actor = Map.get(changeset.context, :actor)
case regenerate_cycles_on_type_change(member, actor: actor) do
{:ok, notifications} -> {:ok, notifications} ->
# Return notifications to Ash - they will be sent automatically after commit # Return notifications to Ash - they will be sent automatically after commit
{:ok, member, notifications} {:ok, member, notifications}
@ -223,7 +230,8 @@ defmodule Mv.Membership.Member do
exit_date_changed = Ash.Changeset.changing_attribute?(changeset, :exit_date) exit_date_changed = Ash.Changeset.changing_attribute?(changeset, :exit_date)
if (join_date_changed || exit_date_changed) && member.membership_fee_type_id do if (join_date_changed || exit_date_changed) && member.membership_fee_type_id do
handle_cycle_generation(member) actor = Map.get(changeset.context, :actor)
handle_cycle_generation(member, actor: actor)
end end
{:error, _} -> {:error, _} ->
@ -294,6 +302,41 @@ defmodule Mv.Membership.Member do
end end
end end
# Authorization Policies
# Order matters: Most specific policies first, then general permission check
policies do
# SYSTEM OPERATIONS: Allow CRUD operations without actor (TEST ENVIRONMENT ONLY)
# In test: All operations allowed (for test fixtures)
# In production/dev: ALL operations denied without actor (fail-closed for security)
# NoActor.check uses compile-time environment detection to prevent security issues
bypass action_type([:create, :read, :update, :destroy]) do
description "Allow system operations without actor (test environment only)"
authorize_if Mv.Authorization.Checks.NoActor
end
# SPECIAL CASE: Users can always READ their linked member
# This allows users with ANY permission set to read their own linked member
# Check using the inverse relationship: User.member_id → Member.id
bypass action_type(:read) do
description "Users can always read member linked to their account"
authorize_if expr(id == ^actor(:member_id))
end
# GENERAL: Check permissions from user's role
# HasPermission handles update permissions correctly:
# - :own_data → can update linked member (scope :linked)
# - :read_only → cannot update any member (no update permission)
# - :normal_user → can update all members (scope :all)
# - :admin → can update all members (scope :all)
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role and permission set"
authorize_if Mv.Authorization.Checks.HasPermission
end
# DEFAULT: Forbid if no policy matched
# Ash implicitly forbids if no policy authorized
end
@doc """ @doc """
Filters members list based on email match priority. Filters members list based on email match priority.
@ -361,8 +404,13 @@ defmodule Mv.Membership.Member do
user_id = user_arg[:id] user_id = user_arg[:id]
current_member_id = changeset.data.id current_member_id = changeset.data.id
# Get actor from changeset context for authorization
# If no actor is present, this will fail in production (fail-closed)
actor = Map.get(changeset.context || %{}, :actor)
# Check the current state of the user in the database # Check the current state of the user in the database
case Ash.get(Mv.Accounts.User, user_id) do # Pass actor to ensure proper authorization (User might have policies in future)
case Ash.get(Mv.Accounts.User, user_id, actor: actor) do
# User is free to be linked # User is free to be linked
{:ok, %{member_id: nil}} -> {:ok, %{member_id: nil}} ->
:ok :ok
@ -600,18 +648,21 @@ defmodule Mv.Membership.Member do
""" """
@spec show_in_overview?(atom()) :: boolean() @spec show_in_overview?(atom()) :: boolean()
def show_in_overview?(field) when is_atom(field) do def show_in_overview?(field) when is_atom(field) do
# exit_date defaults to false (hidden) instead of true
default_visibility = if field == :exit_date, do: false, else: true
case Mv.Membership.get_settings() do case Mv.Membership.get_settings() do
{:ok, settings} -> {:ok, settings} ->
visibility_config = settings.member_field_visibility || %{} visibility_config = settings.member_field_visibility || %{}
# Normalize map keys to atoms (JSONB may return string keys) # Normalize map keys to atoms (JSONB may return string keys)
normalized_config = normalize_visibility_config(visibility_config) normalized_config = VisibilityConfig.normalize(visibility_config)
# Get value from normalized config, default to true # Get value from normalized config, use field-specific default
Map.get(normalized_config, field, true) Map.get(normalized_config, field, default_visibility)
{:error, _} -> {:error, _} ->
# If settings can't be loaded, default to visible # If settings can't be loaded, use field-specific default
true default_visibility
end end
end end
@ -737,33 +788,37 @@ defmodule Mv.Membership.Member do
# Returns {:ok, notifications} or {:error, reason} where notifications are collected # Returns {:ok, notifications} or {:error, reason} where notifications are collected
# to be sent after transaction commits # to be sent after transaction commits
@doc false @doc false
def regenerate_cycles_on_type_change(member) do def regenerate_cycles_on_type_change(member, opts \\ []) do
today = Date.utc_today() today = Date.utc_today()
lock_key = :erlang.phash2(member.id) lock_key = :erlang.phash2(member.id)
actor = Keyword.get(opts, :actor)
# Use advisory lock to prevent concurrent deletion and regeneration # Use advisory lock to prevent concurrent deletion and regeneration
# This ensures atomicity when multiple updates happen simultaneously # This ensures atomicity when multiple updates happen simultaneously
if Mv.Repo.in_transaction?() do if Mv.Repo.in_transaction?() do
regenerate_cycles_in_transaction(member, today, lock_key) regenerate_cycles_in_transaction(member, today, lock_key, actor: actor)
else else
regenerate_cycles_new_transaction(member, today, lock_key) regenerate_cycles_new_transaction(member, today, lock_key, actor: actor)
end end
end end
# Already in transaction: use advisory lock directly # Already in transaction: use advisory lock directly
# Returns {:ok, notifications} - notifications should be returned to after_action hook # Returns {:ok, notifications} - notifications should be returned to after_action hook
defp regenerate_cycles_in_transaction(member, today, lock_key) do defp regenerate_cycles_in_transaction(member, today, lock_key, opts) do
actor = Keyword.get(opts, :actor)
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do_regenerate_cycles_on_type_change(member, today, skip_lock?: true, actor: actor)
end end
# Not in transaction: start new transaction with advisory lock # Not in transaction: start new transaction with advisory lock
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action) # Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
defp regenerate_cycles_new_transaction(member, today, lock_key) do defp regenerate_cycles_new_transaction(member, today, lock_key, opts) do
actor = Keyword.get(opts, :actor)
Mv.Repo.transaction(fn -> Mv.Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true, actor: actor) do
{:ok, notifications} -> {:ok, notifications} ->
# Return notifications - they will be sent by the caller # Return notifications - they will be sent by the caller
notifications notifications
@ -785,6 +840,7 @@ defmodule Mv.Membership.Member do
require Ash.Query require Ash.Query
skip_lock? = Keyword.get(opts, :skip_lock?, false) skip_lock? = Keyword.get(opts, :skip_lock?, false)
actor = Keyword.get(opts, :actor)
# Find all unpaid cycles for this member # Find all unpaid cycles for this member
# We need to check cycle_end for each cycle using its own interval # We need to check cycle_end for each cycle using its own interval
@ -794,10 +850,21 @@ defmodule Mv.Membership.Member do
|> Ash.Query.filter(status == :unpaid) |> Ash.Query.filter(status == :unpaid)
|> Ash.Query.load([:membership_fee_type]) |> Ash.Query.load([:membership_fee_type])
case Ash.read(all_unpaid_cycles_query) do result =
if actor do
Ash.read(all_unpaid_cycles_query, actor: actor)
else
Ash.read(all_unpaid_cycles_query)
end
case result do
{:ok, all_unpaid_cycles} -> {:ok, all_unpaid_cycles} ->
cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today) cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today)
delete_and_regenerate_cycles(cycles_to_delete, member.id, today, skip_lock?: skip_lock?)
delete_and_regenerate_cycles(cycles_to_delete, member.id, today,
skip_lock?: skip_lock?,
actor: actor
)
{:error, reason} -> {:error, reason} ->
{:error, reason} {:error, reason}
@ -826,13 +893,14 @@ defmodule Mv.Membership.Member do
# Returns {:ok, notifications} or {:error, reason} # Returns {:ok, notifications} or {:error, reason}
defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, opts) do defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, opts) do
skip_lock? = Keyword.get(opts, :skip_lock?, false) skip_lock? = Keyword.get(opts, :skip_lock?, false)
actor = Keyword.get(opts, :actor)
if Enum.empty?(cycles_to_delete) do if Enum.empty?(cycles_to_delete) do
# No cycles to delete, just regenerate # No cycles to delete, just regenerate
regenerate_cycles(member_id, today, skip_lock?: skip_lock?) regenerate_cycles(member_id, today, skip_lock?: skip_lock?, actor: actor)
else else
case delete_cycles(cycles_to_delete) do case delete_cycles(cycles_to_delete) do
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?) :ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?, actor: actor)
{:error, reason} -> {:error, reason} {:error, reason} -> {:error, reason}
end end
end end
@ -858,11 +926,13 @@ defmodule Mv.Membership.Member do
# Returns {:ok, notifications} - notifications should be returned to after_action hook # Returns {:ok, notifications} - notifications should be returned to after_action hook
defp regenerate_cycles(member_id, today, opts) do defp regenerate_cycles(member_id, today, opts) do
skip_lock? = Keyword.get(opts, :skip_lock?, false) skip_lock? = Keyword.get(opts, :skip_lock?, false)
actor = Keyword.get(opts, :actor)
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member( case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
member_id, member_id,
today: today, today: today,
skip_lock?: skip_lock? skip_lock?: skip_lock?,
actor: actor
) do ) do
{:ok, _cycles, notifications} when is_list(notifications) -> {:ok, _cycles, notifications} when is_list(notifications) ->
{:ok, notifications} {:ok, notifications}
@ -876,21 +946,25 @@ defmodule Mv.Membership.Member do
# based on environment (test vs production) # based on environment (test vs production)
# This function encapsulates the common logic for cycle generation # This function encapsulates the common logic for cycle generation
# to avoid code duplication across different hooks # to avoid code duplication across different hooks
defp handle_cycle_generation(member) do defp handle_cycle_generation(member, opts) do
actor = Keyword.get(opts, :actor)
if Mv.Config.sql_sandbox?() do if Mv.Config.sql_sandbox?() do
handle_cycle_generation_sync(member) handle_cycle_generation_sync(member, actor: actor)
else else
handle_cycle_generation_async(member) handle_cycle_generation_async(member, actor: actor)
end end
end end
# Runs cycle generation synchronously (for test environment) # Runs cycle generation synchronously (for test environment)
defp handle_cycle_generation_sync(member) do defp handle_cycle_generation_sync(member, opts) do
require Logger require Logger
actor = Keyword.get(opts, :actor)
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member( case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
member.id, member.id,
today: Date.utc_today() today: Date.utc_today(),
actor: actor
) do ) do
{:ok, cycles, notifications} -> {:ok, cycles, notifications} ->
send_notifications_if_any(notifications) send_notifications_if_any(notifications)
@ -902,9 +976,11 @@ defmodule Mv.Membership.Member do
end end
# Runs cycle generation asynchronously (for production environment) # Runs cycle generation asynchronously (for production environment)
defp handle_cycle_generation_async(member) do defp handle_cycle_generation_async(member, opts) do
actor = Keyword.get(opts, :actor)
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn -> Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, actor: actor) do
{:ok, cycles, notifications} -> {:ok, cycles, notifications} ->
send_notifications_if_any(notifications) send_notifications_if_any(notifications)
log_cycle_generation_success(member, cycles, notifications, sync: false) log_cycle_generation_success(member, cycles, notifications, sync: false)
@ -956,29 +1032,6 @@ defmodule Mv.Membership.Member do
defp error_type(error) when is_atom(error), do: error defp error_type(error) when is_atom(error), do: error
defp error_type(_), do: :unknown defp error_type(_), do: :unknown
# Normalizes visibility config map keys from strings to atoms.
# JSONB in PostgreSQL converts atom keys to string keys when storing.
defp normalize_visibility_config(config) when is_map(config) do
Enum.reduce(config, %{}, fn
{key, value}, acc when is_atom(key) ->
Map.put(acc, key, value)
{key, value}, acc when is_binary(key) ->
try do
atom_key = String.to_existing_atom(key)
Map.put(acc, atom_key, value)
rescue
ArgumentError ->
acc
end
_, acc ->
acc
end)
end
defp normalize_visibility_config(_), do: %{}
@doc """ @doc """
Performs fuzzy search on members using PostgreSQL trigram similarity. Performs fuzzy search on members using PostgreSQL trigram similarity.
@ -1156,15 +1209,18 @@ defmodule Mv.Membership.Member do
custom_field_values_arg = Ash.Changeset.get_argument(changeset, :custom_field_values) custom_field_values_arg = Ash.Changeset.get_argument(changeset, :custom_field_values)
if is_nil(custom_field_values_arg) do if is_nil(custom_field_values_arg) do
extract_existing_values(changeset.data) extract_existing_values(changeset.data, changeset)
else else
extract_argument_values(custom_field_values_arg) extract_argument_values(custom_field_values_arg)
end end
end end
# Extracts custom field values from existing member data (update scenario) # Extracts custom field values from existing member data (update scenario)
defp extract_existing_values(member_data) do defp extract_existing_values(member_data, changeset) do
case Ash.load(member_data, :custom_field_values) 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}} -> {:ok, %{custom_field_values: existing_values}} ->
Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2) Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)

View file

@ -57,6 +57,9 @@ defmodule Mv.Membership do
# Settings should be created via seed script # Settings should be created via seed script
define :update_settings, action: :update define :update_settings, action: :update
define :update_member_field_visibility, action: :update_member_field_visibility define :update_member_field_visibility, action: :update_member_field_visibility
define :update_single_member_field_visibility,
action: :update_single_member_field_visibility
end end
end end
@ -89,7 +92,10 @@ defmodule Mv.Membership do
default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name" default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
Mv.Membership.Setting Mv.Membership.Setting
|> Ash.Changeset.for_create(:create, %{club_name: default_club_name}) |> Ash.Changeset.for_create(:create, %{
club_name: default_club_name,
member_field_visibility: %{"exit_date" => false}
})
|> Ash.create!(domain: __MODULE__) |> Ash.create!(domain: __MODULE__)
|> then(fn settings -> {:ok, settings} end) |> then(fn settings -> {:ok, settings} end)
@ -183,4 +189,42 @@ defmodule Mv.Membership do
}) })
|> Ash.update(domain: __MODULE__) |> Ash.update(domain: __MODULE__)
end end
@doc """
Atomically updates a single field in the member field visibility configuration.
This action uses PostgreSQL's jsonb_set function to atomically update a single key
in the JSONB map, preventing lost updates in concurrent scenarios. This is the
preferred method for updating individual field visibility settings.
## Parameters
- `settings` - The settings record to update
- `field` - The member field name as a string (e.g., "street", "house_number")
- `show_in_overview` - Boolean value indicating visibility
## 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_visibility(settings, field: "street", show_in_overview: false)
iex> updated.member_field_visibility["street"]
false
"""
def update_single_member_field_visibility(settings,
field: field,
show_in_overview: show_in_overview
) do
settings
|> Ash.Changeset.new()
|> Ash.Changeset.set_argument(:field, field)
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|> Ash.update(domain: __MODULE__)
end
end end

View file

@ -91,6 +91,16 @@ defmodule Mv.Membership.Setting do
accept [:member_field_visibility] accept [:member_field_visibility]
end end
update :update_single_member_field_visibility do
description "Atomically updates a single field in the member_field_visibility JSONB map"
require_atomic? false
argument :field, :string, allow_nil?: false
argument :show_in_overview, :boolean, allow_nil?: false
change Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility
end
update :update_membership_fee_settings do update :update_membership_fee_settings do
description "Updates the membership fee configuration" description "Updates the membership fee configuration"
require_atomic? false require_atomic? false

View file

@ -0,0 +1,164 @@
defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility do
@moduledoc """
Ash change that atomically updates a single field in the member_field_visibility JSONB map.
This change uses PostgreSQL's jsonb_set function to atomically update a single key
in the JSONB map, preventing lost updates in concurrent scenarios.
## Arguments
- `field` - The member field name as a string (e.g., "street", "house_number")
- `show_in_overview` - Boolean value indicating visibility
## Example
settings
|> Ash.Changeset.for_update(:update_single_member_field_visibility,
%{},
arguments: %{field: "street", show_in_overview: false}
)
|> 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) do
add_after_action(changeset, field, show_in_overview)
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: :member_field_visibility,
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: :member_field_visibility,
message: "Invalid member field: #{field}"
)}
end
end
end
defp get_and_validate_boolean(changeset, arg_name) do
case Ash.Changeset.get_argument(changeset, arg_name) do
nil ->
{:error,
add_error(
changeset,
field: :member_field_visibility,
message: "#{arg_name} argument is required"
)}
value when is_boolean(value) ->
{:ok, value}
_ ->
{:error,
add_error(
changeset,
field: :member_field_visibility,
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) do
# Use after_action to execute atomic SQL update
Ash.Changeset.after_action(changeset, fn _changeset, settings ->
# Use PostgreSQL jsonb_set for atomic update
# jsonb_set(target, path, new_value, create_missing?)
# path is an array: ['field_name']
# new_value must be JSON: to_jsonb(boolean)
sql = """
UPDATE settings
SET member_field_visibility = jsonb_set(
COALESCE(member_field_visibility, '{}'::jsonb),
ARRAY[$1::text],
to_jsonb($2::boolean),
true
)
WHERE id = $3
RETURNING member_field_visibility
"""
# Convert UUID string to binary for PostgreSQL
uuid_binary = Ecto.UUID.dump!(settings.id)
case SQL.query(Mv.Repo, sql, [field, show_in_overview, uuid_binary]) do
{:ok, %{rows: [[updated_jsonb] | _]}} ->
updated_visibility = normalize_jsonb_result(updated_jsonb)
# Update the settings struct with the new visibility
updated_settings = %{settings | member_field_visibility: updated_visibility}
{:ok, updated_settings}
{:ok, %{rows: []}} ->
{:error,
Invalid.exception(
field: :member_field_visibility,
message: "Settings not found"
)}
{:error, error} ->
Logger.error("Failed to atomically update member_field_visibility: #{inspect(error)}")
{:error,
Invalid.exception(
field: :member_field_visibility,
message: "Failed to update visibility"
)}
end
end)
end
defp normalize_jsonb_result(updated_jsonb) do
case updated_jsonb do
map when is_map(map) ->
# Convert atom keys to strings if needed
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
# Not a map after decode
{: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

@ -21,8 +21,8 @@ defmodule Mv.Authorization.Checks.HasPermission do
- **:all** - Authorizes without filtering (returns all records) - **:all** - Authorizes without filtering (returns all records)
- **:own** - Filters to records where record.id == actor.id - **:own** - Filters to records where record.id == actor.id
- **:linked** - Filters based on resource type: - **:linked** - Filters based on resource type:
- Member: member.user.id == actor.id (via has_one :user relationship) - Member: `id == actor.member_id` (User.member_id Member.id, inverse relationship)
- CustomFieldValue: custom_field_value.member.user.id == actor.id (traverses member user relationship!) - CustomFieldValue: `member_id == actor.member_id` (CustomFieldValue.member_id Member.id User.member_id)
## Error Handling ## Error Handling
@ -59,6 +59,7 @@ defmodule Mv.Authorization.Checks.HasPermission do
def strict_check(actor, authorizer, _opts) do def strict_check(actor, authorizer, _opts) do
resource = authorizer.resource resource = authorizer.resource
action = get_action_from_authorizer(authorizer) action = get_action_from_authorizer(authorizer)
record = get_record_from_authorizer(authorizer)
cond do cond do
is_nil(actor) -> is_nil(actor) ->
@ -76,12 +77,12 @@ defmodule Mv.Authorization.Checks.HasPermission do
{:ok, false} {:ok, false}
true -> true ->
strict_check_with_permissions(actor, resource, action) strict_check_with_permissions(actor, resource, action, record)
end end
end end
# Helper function to reduce nesting depth # Helper function to reduce nesting depth
defp strict_check_with_permissions(actor, resource, action) do defp strict_check_with_permissions(actor, resource, action, record) do
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor, with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom), permissions <- PermissionSets.get_permissions(ps_atom),
@ -93,9 +94,15 @@ defmodule Mv.Authorization.Checks.HasPermission do
actor, actor,
resource_name resource_name
) do ) do
:authorized -> {:ok, true} :authorized ->
{:filter, _} -> {:ok, :unknown} {:ok, true}
false -> {:ok, false}
{:filter, filter_expr} ->
# For strict_check on single records, evaluate the filter against the record
evaluate_filter_for_strict_check(filter_expr, actor, record, resource_name)
false ->
{:ok, false}
end end
else else
%{role: nil} -> %{role: nil} ->
@ -122,9 +129,17 @@ defmodule Mv.Authorization.Checks.HasPermission do
action = get_action_from_authorizer(authorizer) action = get_action_from_authorizer(authorizer)
cond do cond do
is_nil(actor) -> nil is_nil(actor) ->
is_nil(action) -> nil # No actor - deny access (fail-closed)
true -> auto_filter_with_permissions(actor, resource, action) # Return filter that never matches (expr(false) = match none)
deny_filter()
is_nil(action) ->
# Cannot determine action - deny access (fail-closed)
deny_filter()
true ->
auto_filter_with_permissions(actor, resource, action)
end end
end end
@ -141,21 +156,97 @@ defmodule Mv.Authorization.Checks.HasPermission do
actor, actor,
resource_name resource_name
) do ) do
:authorized -> nil :authorized ->
{:filter, filter_expr} -> filter_expr # :all scope - allow all records (no filter)
false -> nil # Return empty keyword list (no filtering)
[]
{:filter, filter_expr} ->
# :linked or :own scope - apply filter
# filter_expr is a keyword list from expr(...), return it directly
filter_expr
false ->
# No permission - deny access (fail-closed)
deny_filter()
end end
else else
_ ->
# Error case (no role, invalid permission set, etc.) - deny access (fail-closed)
deny_filter()
end
end
# Helper function to return a filter that never matches (deny all records)
# Used when authorization should be denied (fail-closed)
#
# Using `expr(false)` avoids depending on the primary key being named `:id`.
# This is more robust than [id: {:in, []}] which assumes the primary key is `:id`.
defp deny_filter do
expr(false)
end
# Helper to extract action type from authorizer
# CRITICAL: Must use action_type, not action.name!
# Action types: :create, :read, :update, :destroy
# Action names: :create_member, :update_member, etc.
# PermissionSets uses action types, not action names
#
# Prefer authorizer.action.type (stable API) over authorizer.subject (varies by context)
defp get_action_from_authorizer(authorizer) do
# Primary: Use authorizer.action.type (stable API)
case Map.get(authorizer, :action) do
%{type: action_type} when action_type in [:create, :read, :update, :destroy] ->
action_type
_ ->
# Fallback: Try authorizer.subject (for compatibility with different Ash versions/contexts)
case Map.get(authorizer, :subject) do
%{action_type: action_type} when action_type in [:create, :read, :update, :destroy] ->
action_type
%{action: %{type: action_type}}
when action_type in [:create, :read, :update, :destroy] ->
action_type
_ ->
nil
end
end
end
# Helper to extract record from authorizer for strict_check
defp get_record_from_authorizer(authorizer) do
case authorizer.subject do
%{data: data} when not is_nil(data) -> data
_ -> nil _ -> nil
end end
end end
# Helper to extract action from authorizer # Evaluate filter expression for strict_check on single records
defp get_action_from_authorizer(authorizer) do # For :linked scope with Member resource: id == actor.member_id
case authorizer.subject do defp evaluate_filter_for_strict_check(_filter_expr, actor, record, resource_name) do
%{action: %{name: action}} -> action case {resource_name, record} do
%{action: action} when is_atom(action) -> action {"Member", %{id: member_id}} when not is_nil(member_id) ->
_ -> nil # Check if this member's ID matches the actor's member_id
if member_id == actor.member_id do
{:ok, true}
else
{:ok, false}
end
{"CustomFieldValue", %{member_id: cfv_member_id}} when not is_nil(cfv_member_id) ->
# Check if this CFV's member_id matches the actor's member_id
if cfv_member_id == actor.member_id do
{:ok, true}
else
{:ok, false}
end
_ ->
# For other cases or when record is not available, return :unknown
# This will cause Ash to use auto_filter instead
{:ok, :unknown}
end end
end end
@ -190,21 +281,24 @@ defmodule Mv.Authorization.Checks.HasPermission do
end end
# Scope: linked - Filter based on user relationship (resource-specific!) # Scope: linked - Filter based on user relationship (resource-specific!)
# Uses Ash relationships: Member has_one :user, CustomFieldValue belongs_to :member # IMPORTANT: Understand the relationship direction!
# - User belongs_to :member (User.member_id → Member.id)
# - Member has_one :user (inverse, no FK on Member)
defp apply_scope(:linked, actor, resource_name) do defp apply_scope(:linked, actor, resource_name) do
case resource_name do case resource_name do
"Member" -> "Member" ->
# Member has_one :user → filter by user.id == actor.id # User.member_id → Member.id (inverse relationship)
{:filter, expr(user.id == ^actor.id)} # Filter: member.id == actor.member_id
{:filter, expr(id == ^actor.member_id)}
"CustomFieldValue" -> "CustomFieldValue" ->
# CustomFieldValue belongs_to :member → member has_one :user # CustomFieldValue.member_id → Member.id → User.member_id
# Traverse: custom_field_value.member.user.id == actor.id # Filter: custom_field_value.member_id == actor.member_id
{:filter, expr(member.user.id == ^actor.id)} {:filter, expr(member_id == ^actor.member_id)}
_ -> _ ->
# Fallback for other resources: try user relationship first, then user_id # Fallback for other resources
{:filter, expr(user.id == ^actor.id or user_id == ^actor.id)} {:filter, expr(user_id == ^actor.id)}
end end
end end

View file

@ -0,0 +1,74 @@
defmodule Mv.Authorization.Checks.NoActor do
@moduledoc """
Custom Ash Policy Check that allows actions when no actor is present.
**IMPORTANT:** This check ONLY works in test environment for security reasons.
In production/dev, ALL operations without an actor are denied.
## Security Note
This check uses compile-time environment detection to prevent accidental
security issues in production. In production, ALL operations (including :create
and :read) will be denied if no actor is present.
For seeds and system operations in production, use an admin actor instead:
admin_user = get_admin_user()
Ash.create!(resource, attrs, actor: admin_user)
## Usage in Policies
policies do
# Allow system operations without actor (TEST ENVIRONMENT ONLY)
# In test: All operations allowed
# In production: ALL operations denied (fail-closed)
bypass action_type([:create, :read, :update, :destroy]) do
authorize_if NoActor
end
# Check permissions when actor is present
policy action_type([:read, :create, :update, :destroy]) do
authorize_if HasPermission
end
end
## Behavior
- In test environment: Returns `true` when actor is nil (allows all operations)
- In production/dev: Returns `false` when actor is nil (denies all operations - fail-closed)
- Returns `false` when actor is present (delegates to other policies)
"""
use Ash.Policy.SimpleCheck
# Compile-time check: Only allow no-actor bypass in test environment
@allow_no_actor_bypass Mix.env() == :test
# Alternative (if you want to control via config):
# @allow_no_actor_bypass Application.compile_env(:mv, :allow_no_actor_bypass, false)
@impl true
def describe(_opts) do
if @allow_no_actor_bypass do
"allows actions when no actor is present (test environment only)"
else
"denies all actions when no actor is present (production/dev - fail-closed)"
end
end
@impl true
def match?(nil, _context, _opts) do
# Actor is nil
if @allow_no_actor_bypass do
# Test environment: Allow all operations
true
else
# Production/dev: Deny all operations (fail-closed for security)
false
end
end
def match?(_actor, _context, _opts) do
# Actor is present - don't match (let other policies decide)
false
end
end

View file

@ -41,8 +41,10 @@ defmodule Mv.EmailSync.Changes.SyncMemberEmailToUser do
Ash.Changeset.around_transaction(changeset, fn cs, callback -> Ash.Changeset.around_transaction(changeset, fn cs, callback ->
result = callback.(cs) result = callback.(cs)
actor = Map.get(changeset.context, :actor)
with {:ok, member} <- Helpers.extract_record(result), with {:ok, member} <- Helpers.extract_record(result),
linked_user <- Loader.get_linked_user(member) do linked_user <- Loader.get_linked_user(member, actor) do
Helpers.sync_email_to_linked_record(result, linked_user, new_email) Helpers.sync_email_to_linked_record(result, linked_user, new_email)
else else
_ -> result _ -> result

View file

@ -33,7 +33,17 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
if Map.get(context, :syncing_email, false) do if Map.get(context, :syncing_email, false) do
changeset changeset
else else
sync_email(changeset) # Ensure actor is in changeset context - get it from context if available
actor = Map.get(changeset.context, :actor) || Map.get(context, :actor)
changeset_with_actor =
if actor && !Map.has_key?(changeset.context, :actor) do
Ash.Changeset.put_context(changeset, :actor, actor)
else
changeset
end
sync_email(changeset_with_actor)
end end
end end
@ -42,7 +52,7 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
result = callback.(cs) result = callback.(cs)
with {:ok, record} <- Helpers.extract_record(result), with {:ok, record} <- Helpers.extract_record(result),
{:ok, user, member} <- get_user_and_member(record) do {:ok, user, member} <- get_user_and_member(record, cs) do
# When called from Member-side, we need to update the member in the result # When called from Member-side, we need to update the member in the result
# When called from User-side, we update the linked member in DB only # When called from User-side, we update the linked member in DB only
case record do case record do
@ -61,15 +71,19 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
end end
# Retrieves user and member - works for both resource types # Retrieves user and member - works for both resource types
defp get_user_and_member(%Mv.Accounts.User{} = user) do defp get_user_and_member(%Mv.Accounts.User{} = user, changeset) do
case Loader.get_linked_member(user) do actor = Map.get(changeset.context, :actor)
case Loader.get_linked_member(user, actor) do
nil -> {:error, :no_member} nil -> {:error, :no_member}
member -> {:ok, user, member} member -> {:ok, user, member}
end end
end end
defp get_user_and_member(%Mv.Membership.Member{} = member) do defp get_user_and_member(%Mv.Membership.Member{} = member, changeset) do
case Loader.load_linked_user!(member) do actor = Map.get(changeset.context, :actor)
case Loader.load_linked_user!(member, actor) do
{:ok, user} -> {:ok, user, member} {:ok, user} -> {:ok, user, member}
error -> error error -> error
end end

View file

@ -2,15 +2,30 @@ defmodule Mv.EmailSync.Loader do
@moduledoc """ @moduledoc """
Helper functions for loading linked records in email synchronization. Helper functions for loading linked records in email synchronization.
Centralizes the logic for retrieving related User/Member entities. Centralizes the logic for retrieving related User/Member entities.
## Authorization
This module runs systemically and accepts optional actor parameters.
When called from hooks/changes, actor is extracted from changeset context.
When called directly, actor should be provided for proper authorization.
All functions accept an optional `actor` parameter that is passed to Ash operations
to ensure proper authorization checks are performed.
""" """
alias Mv.Helpers
@doc """ @doc """
Loads the member linked to a user, returns nil if not linked or on error. Loads the member linked to a user, returns nil if not linked or on error.
"""
def get_linked_member(%{member_id: nil}), do: nil
def get_linked_member(%{member_id: id}) do Accepts optional actor for authorization.
case Ash.get(Mv.Membership.Member, id) do """
def get_linked_member(user, actor \\ nil)
def get_linked_member(%{member_id: nil}, _actor), do: nil
def get_linked_member(%{member_id: id}, actor) do
opts = Helpers.ash_actor_opts(actor)
case Ash.get(Mv.Membership.Member, id, opts) do
{:ok, member} -> member {:ok, member} -> member
{:error, _} -> nil {:error, _} -> nil
end end
@ -18,9 +33,13 @@ defmodule Mv.EmailSync.Loader do
@doc """ @doc """
Loads the user linked to a member, returns nil if not linked or on error. Loads the user linked to a member, returns nil if not linked or on error.
Accepts optional actor for authorization.
""" """
def get_linked_user(member) do def get_linked_user(member, actor \\ nil) do
case Ash.load(member, :user) do opts = Helpers.ash_actor_opts(actor)
case Ash.load(member, :user, opts) do
{:ok, %{user: user}} -> user {:ok, %{user: user}} -> user
{:error, _} -> nil {:error, _} -> nil
end end
@ -29,9 +48,13 @@ defmodule Mv.EmailSync.Loader do
@doc """ @doc """
Loads the user linked to a member, returning an error tuple if not linked. Loads the user linked to a member, returning an error tuple if not linked.
Useful when a link is required for the operation. Useful when a link is required for the operation.
Accepts optional actor for authorization.
""" """
def load_linked_user!(member) do def load_linked_user!(member, actor \\ nil) do
case Ash.load(member, :user) do opts = Helpers.ash_actor_opts(actor)
case Ash.load(member, :user, opts) do
{:ok, %{user: user}} when not is_nil(user) -> {:ok, user} {:ok, %{user: user}} when not is_nil(user) -> {:ok, user}
{:ok, _} -> {:error, :no_linked_user} {:ok, _} -> {:error, :no_linked_user}
{:error, _} = error -> error {:error, _} = error -> error

27
lib/mv/helpers.ex Normal file
View file

@ -0,0 +1,27 @@
defmodule Mv.Helpers do
@moduledoc """
Shared helper functions used across the Mv application.
Provides utilities that are not specific to a single domain or layer.
"""
@doc """
Converts an actor to Ash options list for authorization.
Returns empty list if actor is nil.
This helper ensures consistent actor handling across all Ash operations
in the application, whether called from LiveViews, changes, validations,
or other contexts.
## Examples
opts = ash_actor_opts(actor)
Ash.read(query, opts)
opts = ash_actor_opts(nil)
# => []
"""
@spec ash_actor_opts(Mv.Accounts.User.t() | nil) :: keyword()
def ash_actor_opts(nil), do: []
def ash_actor_opts(actor) when not is_nil(actor), do: [actor: actor]
end

View file

@ -0,0 +1,49 @@
defmodule Mv.Helpers.TypeParsers do
@moduledoc """
Helper functions for parsing various input types to common Elixir types.
Provides safe parsing functions for common type conversions, especially useful
when dealing with form data or external APIs.
"""
@doc """
Parses various input types to boolean.
Handles: booleans, strings ("true"/"false"), integers (1/0), and other values (defaults to false).
## Parameters
- `value` - The value to parse (boolean, string, integer, or other)
## Returns
A boolean value
## Examples
iex> parse_boolean(true)
true
iex> parse_boolean("true")
true
iex> parse_boolean("false")
false
iex> parse_boolean(1)
true
iex> parse_boolean(0)
false
iex> parse_boolean(nil)
false
"""
@spec parse_boolean(any()) :: boolean()
def parse_boolean(value) when is_boolean(value), do: value
def parse_boolean("true"), do: true
def parse_boolean("false"), do: false
def parse_boolean(1), do: true
def parse_boolean(0), do: false
def parse_boolean(_), do: false
end

View file

@ -0,0 +1,55 @@
defmodule Mv.Membership.Helpers.VisibilityConfig do
@moduledoc """
Helper functions for normalizing member field visibility configuration.
Handles conversion between string keys (from JSONB) and atom keys (Elixir convention).
JSONB in PostgreSQL converts atom keys to string keys when storing.
This module provides functions to normalize these back to atoms for Elixir usage.
"""
@doc """
Normalizes visibility config map keys from strings to atoms.
JSONB in PostgreSQL converts atom keys to string keys when storing.
This function converts them back to atoms for Elixir usage.
## Parameters
- `config` - A map with either string or atom keys
## Returns
A map with atom keys (where possible)
## Examples
iex> normalize(%{"first_name" => true, "email" => false})
%{first_name: true, email: false}
iex> normalize(%{first_name: true, email: false})
%{first_name: true, email: false}
iex> normalize(%{"invalid_field" => true})
%{}
"""
@spec normalize(map()) :: map()
def normalize(config) when is_map(config) do
Enum.reduce(config, %{}, fn
{key, value}, acc when is_atom(key) ->
Map.put(acc, key, value)
{key, value}, acc when is_binary(key) ->
try do
atom_key = String.to_existing_atom(key)
Map.put(acc, atom_key, value)
rescue
ArgumentError -> acc
end
_, acc ->
acc
end)
end
def normalize(_), do: %{}
end

View file

@ -8,6 +8,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
This allows creating members with the same email as unlinked users. This allows creating members with the same email as unlinked users.
""" """
use Ash.Resource.Validation use Ash.Resource.Validation
alias Mv.Helpers
@doc """ @doc """
Validates email uniqueness across linked Member-User pairs. Validates email uniqueness across linked Member-User pairs.
@ -29,7 +30,8 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
def validate(changeset, _opts, _context) do def validate(changeset, _opts, _context) do
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email) email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
linked_user_id = get_linked_user_id(changeset.data) actor = Map.get(changeset.context || %{}, :actor)
linked_user_id = get_linked_user_id(changeset.data, actor)
is_linked? = not is_nil(linked_user_id) is_linked? = not is_nil(linked_user_id)
# Only validate if member is already linked AND email is changing # Only validate if member is already linked AND email is changing
@ -38,19 +40,21 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
if should_validate? do if should_validate? do
new_email = Ash.Changeset.get_attribute(changeset, :email) new_email = Ash.Changeset.get_attribute(changeset, :email)
check_email_uniqueness(new_email, linked_user_id) check_email_uniqueness(new_email, linked_user_id, actor)
else else
:ok :ok
end end
end end
defp check_email_uniqueness(email, exclude_user_id) do defp check_email_uniqueness(email, exclude_user_id, actor) do
query = query =
Mv.Accounts.User Mv.Accounts.User
|> Ash.Query.filter(email == ^email) |> Ash.Query.filter(email == ^email)
|> maybe_exclude_id(exclude_user_id) |> maybe_exclude_id(exclude_user_id)
case Ash.read(query) do opts = Helpers.ash_actor_opts(actor)
case Ash.read(query, opts) do
{:ok, []} -> {:ok, []} ->
:ok :ok
@ -65,8 +69,10 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
defp maybe_exclude_id(query, nil), do: query defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id) defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
defp get_linked_user_id(member_data) do defp get_linked_user_id(member_data, actor) do
case Ash.load(member_data, :user) do opts = Helpers.ash_actor_opts(actor)
case Ash.load(member_data, :user, opts) do
{:ok, %{user: %{id: id}}} -> id {:ok, %{user: %{id: id}}} -> id
_ -> nil _ -> nil
end end

View file

@ -28,6 +28,15 @@ defmodule Mv.MembershipFees.CycleGenerator do
Uses PostgreSQL advisory locks to prevent race conditions when generating Uses PostgreSQL advisory locks to prevent race conditions when generating
cycles for the same member concurrently. cycles for the same member concurrently.
## Authorization
This module runs systemically and accepts optional actor parameters.
When called from hooks/changes, actor is extracted from changeset context.
When called directly, actor should be provided for proper authorization.
All functions accept an optional `actor` parameter in the `opts` keyword list
that is passed to Ash operations to ensure proper authorization checks are performed.
## Examples ## Examples
# Generate cycles for a single member # Generate cycles for a single member
@ -77,7 +86,9 @@ defmodule Mv.MembershipFees.CycleGenerator do
def generate_cycles_for_member(member_or_id, opts \\ []) def generate_cycles_for_member(member_or_id, opts \\ [])
def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do
case load_member(member_id) do actor = Keyword.get(opts, :actor)
case load_member(member_id, actor: actor) do
{:ok, member} -> generate_cycles_for_member(member, opts) {:ok, member} -> generate_cycles_for_member(member, opts)
{:error, reason} -> {:error, reason} {:error, reason} -> {:error, reason}
end end
@ -87,25 +98,27 @@ defmodule Mv.MembershipFees.CycleGenerator do
today = Keyword.get(opts, :today, Date.utc_today()) today = Keyword.get(opts, :today, Date.utc_today())
skip_lock? = Keyword.get(opts, :skip_lock?, false) skip_lock? = Keyword.get(opts, :skip_lock?, false)
do_generate_cycles_with_lock(member, today, skip_lock?) do_generate_cycles_with_lock(member, today, skip_lock?, opts)
end end
# Generate cycles with lock handling # Generate cycles with lock handling
# Returns {:ok, cycles, notifications} - notifications are never sent here, # Returns {:ok, cycles, notifications} - notifications are never sent here,
# they should be returned to the caller (e.g., via after_action hook) # they should be returned to the caller (e.g., via after_action hook)
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?) do defp do_generate_cycles_with_lock(member, today, true = _skip_lock?, opts) do
# Lock already set by caller (e.g., regenerate_cycles_on_type_change) # Lock already set by caller (e.g., regenerate_cycles_on_type_change)
# Just generate cycles without additional locking # Just generate cycles without additional locking
do_generate_cycles(member, today) actor = Keyword.get(opts, :actor)
do_generate_cycles(member, today, actor: actor)
end end
defp do_generate_cycles_with_lock(member, today, false) do defp do_generate_cycles_with_lock(member, today, false, opts) do
lock_key = :erlang.phash2(member.id) lock_key = :erlang.phash2(member.id)
actor = Keyword.get(opts, :actor)
Repo.transaction(fn -> Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_generate_cycles(member, today) do case do_generate_cycles(member, today, actor: actor) do
{:ok, cycles, notifications} -> {:ok, cycles, notifications} ->
# Return cycles and notifications - do NOT send notifications here # Return cycles and notifications - do NOT send notifications here
# They will be sent by the caller (e.g., via after_action hook) # They will be sent by the caller (e.g., via after_action hook)
@ -222,21 +235,33 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Private functions # Private functions
defp load_member(member_id) do defp load_member(member_id, opts) do
actor = Keyword.get(opts, :actor)
query =
Member Member
|> Ash.Query.filter(id == ^member_id) |> Ash.Query.filter(id == ^member_id)
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles]) |> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])
|> Ash.read_one()
|> case do result =
if actor do
Ash.read_one(query, actor: actor)
else
Ash.read_one(query)
end
case result do
{:ok, nil} -> {:error, :member_not_found} {:ok, nil} -> {:error, :member_not_found}
{:ok, member} -> {:ok, member} {:ok, member} -> {:ok, member}
{:error, reason} -> {:error, reason} {:error, reason} -> {:error, reason}
end end
end end
defp do_generate_cycles(member, today) do defp do_generate_cycles(member, today, opts) do
actor = Keyword.get(opts, :actor)
# Reload member with relationships to ensure fresh data # Reload member with relationships to ensure fresh data
case load_member(member.id) do case load_member(member.id, actor: actor) do
{:ok, member} -> {:ok, member} ->
cond do cond do
is_nil(member.membership_fee_type_id) -> is_nil(member.membership_fee_type_id) ->
@ -246,7 +271,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
{:error, :no_join_date} {:error, :no_join_date}
true -> true ->
generate_missing_cycles(member, today) generate_missing_cycles(member, today, actor: actor)
end end
{:error, reason} -> {:error, reason} ->
@ -254,7 +279,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
end end
end end
defp generate_missing_cycles(member, today) do defp generate_missing_cycles(member, today, opts) do
actor = Keyword.get(opts, :actor)
fee_type = member.membership_fee_type fee_type = member.membership_fee_type
interval = fee_type.interval interval = fee_type.interval
amount = fee_type.amount amount = fee_type.amount
@ -270,7 +296,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Only generate if start_date <= end_date # Only generate if start_date <= end_date
if start_date && Date.compare(start_date, end_date) != :gt do if start_date && Date.compare(start_date, end_date) != :gt do
cycle_starts = generate_cycle_starts(start_date, end_date, interval) cycle_starts = generate_cycle_starts(start_date, end_date, interval)
create_cycles(cycle_starts, member.id, fee_type.id, amount) create_cycles(cycle_starts, member.id, fee_type.id, amount, actor: actor)
else else
{:ok, [], []} {:ok, [], []}
end end
@ -365,7 +391,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
end end
end end
defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do
actor = Keyword.get(opts, :actor)
# Always use return_notifications?: true to collect notifications # Always use return_notifications?: true to collect notifications
# Notifications will be returned to the caller, who is responsible for # Notifications will be returned to the caller, who is responsible for
# sending them (e.g., via after_action hook returning {:ok, result, notifications}) # sending them (e.g., via after_action hook returning {:ok, result, notifications})
@ -380,7 +407,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
} }
handle_cycle_creation_result( handle_cycle_creation_result(
Ash.create(MembershipFeeCycle, attrs, return_notifications?: true), Ash.create(MembershipFeeCycle, attrs, return_notifications?: true, actor: actor),
cycle_start cycle_start
) )
end) end)

View file

@ -17,7 +17,7 @@ defmodule MvWeb do
those modules here. those modules here.
""" """
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) def static_paths, do: ~w(assets fonts images favicon.ico robots.txt templates)
def router do def router do
quote do quote do

View file

@ -692,10 +692,11 @@ defmodule MvWeb.CoreComponents do
""" """
attr :name, :string, required: true attr :name, :string, required: true
attr :class, :string, default: "size-4" attr :class, :string, default: "size-4"
attr :rest, :global, include: ~w(aria-hidden)
def icon(%{name: "hero-" <> _} = assigns) do def icon(%{name: "hero-" <> _} = assigns) do
~H""" ~H"""
<span class={[@name, @class]} /> <span class={[@name, @class]} {@rest} />
""" """
end end

View file

@ -9,7 +9,7 @@ defmodule MvWeb.Layouts do
""" """
use MvWeb, :html use MvWeb, :html
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
import MvWeb.Layouts.Navbar import MvWeb.Layouts.Sidebar
embed_templates "layouts/*" embed_templates "layouts/*"
@ -43,20 +43,66 @@ defmodule MvWeb.Layouts do
slot :inner_block, required: true slot :inner_block, required: true
def app(assigns) do def app(assigns) do
club_name = get_club_name()
assigns = assign(assigns, :club_name, club_name)
~H""" ~H"""
<%= if @current_user do %> <%= if @current_user do %>
<.navbar current_user={@current_user} club_name={@club_name} /> <div
<% end %> id="app-layout"
<main class="px-4 py-20 sm:px-6 lg:px-16"> class="drawer lg:drawer-open"
<div class="mx-auto max-full space-y-4"> data-sidebar-expanded="true"
phx-hook="SidebarState"
>
<input id="mobile-drawer" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col relative z-0">
<!-- Mobile Header (only visible on mobile) -->
<header class="lg:hidden sticky top-0 z-10 navbar bg-base-100 shadow-sm">
<label
for="mobile-drawer"
class="btn btn-square btn-ghost"
aria-label={gettext("Open navigation menu")}
>
<.icon name="hero-bars-3" class="size-6" aria-hidden="true" />
</label>
<span class="font-bold">{@club_name}</span>
</header>
<!-- Main Content (shared between mobile and desktop) -->
<main class="px-4 py-8 sm:px-6 lg:px-8">
<div class="mx-auto space-y-4 max-full">
{render_slot(@inner_block)} {render_slot(@inner_block)}
</div> </div>
</main> </main>
</div>
<div class="drawer-side z-40">
<.sidebar current_user={@current_user} club_name={@club_name} mobile={false} />
</div>
</div>
<% else %>
<!-- Not logged in -->
<main class="px-4 py-8 sm:px-6">
<div class="mx-auto space-y-4 max-full">
{render_slot(@inner_block)}
</div>
</main>
<% end %>
<.flash_group flash={@flash} /> <.flash_group flash={@flash} />
""" """
end end
# Helper function to get club name from settings
# Falls back to "Mitgliederverwaltung" if settings can't be loaded
defp get_club_name do
case Mv.Membership.get_settings() do
{:ok, settings} -> settings.club_name
_ -> "Mitgliederverwaltung"
end
end
@doc """ @doc """
Shows the flash group with standard titles and content. Shows the flash group with standard titles and content.
@ -69,7 +115,7 @@ defmodule MvWeb.Layouts do
def flash_group(assigns) do def flash_group(assigns) do
~H""" ~H"""
<div id={@id} aria-live="polite" class="toast toast-top toast-end z-50 flex flex-col gap-2"> <div id={@id} aria-live="polite" class="z-50 flex flex-col gap-2 toast toast-top toast-end">
<.flash kind={:success} flash={@flash} /> <.flash kind={:success} flash={@flash} />
<.flash kind={:warning} flash={@flash} /> <.flash kind={:warning} flash={@flash} />
<.flash kind={:info} flash={@flash} /> <.flash kind={:info} flash={@flash} />

View file

@ -1,152 +0,0 @@
defmodule MvWeb.Layouts.Navbar do
@moduledoc """
Navbar that is used in the rootlayout shown on every page
"""
use Phoenix.Component
use Gettext, backend: MvWeb.Gettext
use MvWeb, :verified_routes
alias Mv.Membership
import MvWeb.Authorization
attr :current_user, :map,
required: true,
doc: "The current user - navbar is only shown when user is present"
attr :club_name, :string,
default: nil,
doc: "Optional club name - if not provided, will be loaded from database"
def navbar(assigns) do
club_name = assigns[:club_name] || get_club_name()
assigns = assign(assigns, :club_name, club_name)
~H"""
<header class="navbar bg-base-100 shadow-sm">
<div class="flex-1">
<a href="/members" class="btn btn-ghost text-xl">{@club_name}</a>
<ul class="menu menu-horizontal bg-base-200">
<li><.link navigate="/members">{gettext("Members")}</.link></li>
<li>
<details>
<summary>{gettext("Settings")}</summary>
<ul class="bg-base-200 rounded-t-none p-2 z-10 w-48">
<li>
<.link navigate="/settings">{gettext("Global Settings")}</.link>
</li>
<%= if can_access_page?(@current_user, ~p"/admin/roles") do %>
<li>
<.link navigate={~p"/admin/roles"}>{gettext("Roles")}</.link>
</li>
<% end %>
</ul>
</details>
</li>
<li><.link navigate="/users">{gettext("Users")}</.link></li>
<li>
<details>
<summary>{gettext("Contributions")}</summary>
<ul class="bg-base-200 rounded-t-none p-2 z-10 w-48">
<li>
<.link navigate="/membership_fee_types">{gettext("Membership Fee Types")}</.link>
</li>
<li>
<.link navigate="/membership_fee_settings">
{gettext("Membership Fee Settings")}
</.link>
</li>
</ul>
</details>
</li>
</ul>
</div>
<div class="flex gap-2">
<form method="post" action="/set_locale" class="mr-4">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<label class="sr-only" for="locale-select">{gettext("Select language")}</label>
<select
id="locale-select"
name="locale"
onchange="this.form.submit()"
class="select select-sm"
aria-label={gettext("Select language")}
>
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
</select>
</form>
<!-- Daisy UI Theme Toggle for dark and light mode-->
<label class="flex cursor-pointer gap-2" aria-label={gettext("Toggle dark mode")}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="5" />
<path d="M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4" />
</svg>
<input
type="checkbox"
value="dark"
class="toggle theme-controller"
aria-label={gettext("Toggle dark mode")}
/>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</label>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar avatar-placeholder">
<div class="bg-neutral text-neutral-content w-12 rounded-full">
<span>AA</span>
</div>
</div>
<ul
tabindex="0"
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow"
>
<li>
<.link navigate={~p"/users/#{@current_user.id}"}>
{gettext("Profil")}
</.link>
</li>
<li>
<.link navigate={~p"/settings"}>{gettext("Settings")}</.link>
</li>
<li>
<.link href={~p"/sign-out"}>{gettext("Logout")}</.link>
</li>
</ul>
</div>
</div>
</header>
"""
end
# Helper function to get club name from settings
# Falls back to "Mitgliederverwaltung" if settings can't be loaded
defp get_club_name do
case Membership.get_settings() do
{:ok, settings} -> settings.club_name
_ -> "Mitgliederverwaltung"
end
end
end

View file

@ -0,0 +1,317 @@
defmodule MvWeb.Layouts.Sidebar do
@moduledoc """
Sidebar navigation component used in the drawer layout
"""
use MvWeb, :html
attr :current_user, :map, default: nil, doc: "The current user"
attr :club_name, :string, required: true, doc: "The name of the club"
attr :mobile, :boolean, default: false, doc: "Whether this is mobile view"
def sidebar(assigns) do
~H"""
<label
for="mobile-drawer"
aria-label={gettext("Close sidebar")}
class="drawer-overlay lg:hidden focus:outline-none focus:ring-2 focus:ring-primary"
tabindex="-1"
>
</label>
<aside id="main-sidebar" class="sidebar" aria-label={gettext("Main navigation")}>
{sidebar_header(assigns)}
<%= if @current_user do %>
{sidebar_menu(assigns)}
<% end %>
<%= if @current_user do %>
<.sidebar_footer current_user={@current_user} />
<% end %>
</aside>
"""
end
defp sidebar_header(assigns) do
~H"""
<div class="flex items-center gap-3 p-4 border-b border-base-300">
<!-- Logo -->
<img src={~p"/images/mila.svg"} alt="Mila Logo" class="size-8 shrink-0" />
<!-- Club Name -->
<span class="menu-label text-lg font-bold truncate">
{@club_name}
</span>
<!-- Toggle Button (Desktop only) -->
<%= unless @mobile do %>
<button
type="button"
id="sidebar-toggle"
class="hidden lg:flex ml-auto btn btn-ghost btn-sm btn-square"
aria-label={gettext("Toggle sidebar")}
aria-controls="main-sidebar"
aria-expanded="true"
onclick="toggleSidebar()"
>
<!-- Expanded Icon (Chevron Left) -->
<.icon
name="hero-chevron-left"
class="size-5 sidebar-expanded-icon"
aria-hidden="true"
/>
<!-- Collapsed Icon (Chevron Right) -->
<.icon
name="hero-chevron-right"
class="size-5 sidebar-collapsed-icon"
aria-hidden="true"
/>
</button>
<% end %>
</div>
"""
end
defp sidebar_menu(assigns) do
~H"""
<ul class="menu flex-1 w-full p-2" role="menubar">
<.menu_item
href={~p"/members"}
icon="hero-users"
label={gettext("Members")}
/>
<.menu_item
href={~p"/users"}
icon="hero-user-circle"
label={gettext("Users")}
/>
<.menu_item
href={~p"/custom_field_values"}
icon="hero-rectangle-group"
label={gettext("Custom Fields")}
/>
<!-- Nested Menu: Contributions -->
<.menu_group
icon="hero-currency-dollar"
label={gettext("Contributions")}
>
<.menu_subitem href="/contribution_types" label={gettext("Contribution Types")} />
<.menu_subitem href="/membership_fee_settings" label={gettext("Settings")} />
</.menu_group>
<.menu_item
href={~p"/settings"}
icon="hero-cog-6-tooth"
label={gettext("Settings")}
/>
</ul>
"""
end
attr :href, :string, required: true, doc: "Navigation path"
attr :icon, :string, required: true, doc: "Heroicon name"
attr :label, :string, required: true, doc: "Menu item label"
defp menu_item(assigns) do
~H"""
<li role="none">
<.link
navigate={@href}
class="flex items-center gap-3 tooltip tooltip-right focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
data-tip={@label}
role="menuitem"
>
<.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" />
<span class="menu-label">{@label}</span>
</.link>
</li>
"""
end
attr :icon, :string, required: true, doc: "Heroicon name for the menu group"
attr :label, :string, required: true, doc: "Menu group label"
slot :inner_block, required: true, doc: "Submenu items"
defp menu_group(assigns) do
~H"""
<li role="none" class="menu-group">
<!-- Expanded Mode: Details/Summary -->
<details class="expanded-menu-group">
<summary
class="flex items-center gap-3 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
role="menuitem"
aria-haspopup="true"
>
<.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" />
<span class="menu-label">{@label}</span>
</summary>
<ul role="menu" class="ml-4">
{render_slot(@inner_block)}
</ul>
</details>
<!-- Collapsed Mode: Dropdown -->
<div class="collapsed-menu-group dropdown dropdown-right">
<button
type="button"
tabindex="0"
class="flex items-center w-full p-2 rounded-lg hover:bg-base-300 tooltip tooltip-right focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
data-tip={@label}
aria-haspopup="menu"
aria-label={@label}
>
<.icon name={@icon} class="size-5" aria-hidden="true" />
</button>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box shadow-lg z-50 min-w-48 p-2 focus:outline-none"
role="menu"
>
<li class="menu-title">{@label}</li>
{render_slot(@inner_block)}
</ul>
</div>
</li>
"""
end
attr :href, :string, required: true, doc: "Navigation path for submenu item"
attr :label, :string, required: true, doc: "Submenu item label"
defp menu_subitem(assigns) do
~H"""
<li role="none">
<.link
navigate={@href}
role="menuitem"
class="focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
>
{@label}
</.link>
</li>
"""
end
attr :current_user, :map, default: nil, doc: "The current user"
defp sidebar_footer(assigns) do
~H"""
<div class="mt-auto p-4 border-t border-base-300 space-y-4">
<!-- Language Selector (nur expanded) -->
<form method="post" action={~p"/set_locale"} class="expanded-only">
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<select
name="locale"
onchange="this.form.submit()"
class="select select-sm w-full focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
aria-label={gettext("Select language")}
>
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
</select>
</form>
<!-- Theme Toggle (immer sichtbar) -->
<.theme_toggle />
<!-- User Menu (nur wenn current_user existiert) -->
<%= if @current_user do %>
<.user_menu current_user={@current_user} />
<% end %>
</div>
"""
end
defp theme_toggle(assigns) do
~H"""
<label
class="flex items-center gap-2 cursor-pointer justify-center focus-within:outline-none focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2"
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")}
/>
<.icon name="hero-moon" class="size-5" aria-hidden="true" />
</label>
"""
end
attr :current_user, :map, default: nil, doc: "The current user"
defp user_menu(assigns) do
# Defensive check: ensure current_user and email exist
# current_user might be a struct, so we need to handle both maps and structs
# email might be an Ash.CiString, so we use to_string/1 (not String.to_string/1)
email =
case assigns.current_user do
nil -> ""
%{email: email_val} when not is_nil(email_val) -> to_string(email_val)
_ -> ""
end
first_letter =
if email != "" do
String.first(email) |> String.upcase()
else
"?"
end
user_id =
case assigns.current_user do
nil -> nil
%{id: id_val} when not is_nil(id_val) -> id_val
_ -> nil
end
# Store computed values in assigns to avoid HEEx warnings
assigns = assign(assigns, :email, email)
assigns = assign(assigns, :first_letter, first_letter)
assigns = assign(assigns, :user_id, user_id)
~H"""
<div class="dropdown dropdown-top dropdown-end w-full">
<button
type="button"
tabindex="0"
class="user-menu-button flex items-center w-full justify-start gap-3 px-4 h-12 min-h-12 rounded-lg cursor-pointer focus:outline-none"
aria-label={gettext("User menu")}
aria-haspopup="menu"
>
<!-- Avatar: Placeholder with first letter -->
<div class="avatar placeholder shrink-0">
<div class="w-8 h-8 rounded-full bg-neutral text-neutral-content">
<span class="text-sm font-semibold">
{@first_letter}
</span>
</div>
</div>
<span class="menu-label truncate flex-1 text-left">{@email}</span>
</button>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box shadow-lg w-52 p-2 focus:outline-none absolute z-[100]"
role="menu"
>
<li role="none">
<%= if @user_id do %>
<.link
navigate={~p"/users/#{@user_id}"}
role="menuitem"
class="focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
>
{gettext("Profile")}
</.link>
<% else %>
<span class="opacity-50">{gettext("Profile")}</span>
<% end %>
</li>
<li role="none">
<.link
href={~p"/sign-out"}
role="menuitem"
class="focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
>
{gettext("Logout")}
</.link>
</li>
</ul>
</div>
"""
end
end

View file

@ -78,6 +78,12 @@ defmodule MvWeb.AuthController do
end end
end 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."))
end
# Handle generic AuthenticationFailed errors # Handle generic AuthenticationFailed errors
defp handle_authentication_failed(conn, %Ash.Error.Forbidden{errors: errors}) do defp handle_authentication_failed(conn, %Ash.Error.Forbidden{errors: errors}) do
if Enum.any?(errors, &match?(%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}, &1)) do if Enum.any?(errors, &match?(%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}, &1)) do

View file

@ -0,0 +1,59 @@
defmodule MvWeb.Helpers.FieldTypeFormatter do
@moduledoc """
Helper functions for formatting field types for display.
Handles both Ash type modules (e.g., `Ash.Type.String`) and simple atoms (e.g., `:string`).
"""
alias MvWeb.Translations.FieldTypes
@doc """
Formats an Ash type for display.
Handles both Ash type modules (e.g., `Ash.Type.String`) and simple atoms (e.g., `:string`).
## Parameters
- `type` - An atom or module representing the field type
## Returns
A human-readable string representation of the type
## Examples
iex> format(:string)
"String"
iex> format(Ash.Type.String)
"String"
iex> format(Ash.Type.Date)
"Date"
"""
@spec format(atom() | module()) :: String.t()
def format(type) when is_atom(type) do
type_string = to_string(type)
if String.contains?(type_string, "Ash.Type.") do
type_string
|> String.split(".")
|> List.last()
|> String.downcase()
|> then(fn type_name ->
try do
type_atom = String.to_existing_atom(type_name)
FieldTypes.label(type_atom)
rescue
ArgumentError -> FieldTypes.label(:string)
end
end)
else
FieldTypes.label(type)
end
end
def format(type) do
to_string(type)
end
end

View file

@ -8,9 +8,9 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
alias Mv.Membership.Member
alias Mv.MembershipFees.CalendarCycles alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.Membership.Member
@doc """ @doc """
Formats a decimal amount as currency string. Formats a decimal amount as currency string.

View file

@ -18,6 +18,8 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
use MvWeb, :live_component use MvWeb, :live_component
alias MvWeb.Translations.MemberFields
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# UPDATE # UPDATE
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -66,7 +68,7 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
<.dropdown_menu <.dropdown_menu
id="field-visibility-menu" id="field-visibility-menu"
icon="hero-adjustments-horizontal" icon="hero-adjustments-horizontal"
button_label={gettext("Columns")} button_label={gettext("Show/Hide Columns")}
items={@all_items} items={@all_items}
checkboxes={true} checkboxes={true}
selected={@selected_fields} selected={@selected_fields}
@ -153,12 +155,12 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
defp field_to_string(field) when is_binary(field), do: field defp field_to_string(field) when is_binary(field), do: field
defp format_field_label(field) when is_atom(field) do defp format_field_label(field) when is_atom(field) do
MvWeb.Translations.MemberFields.label(field) MemberFields.label(field)
end end
defp format_field_label(field) when is_binary(field) do defp format_field_label(field) when is_binary(field) do
case safe_to_existing_atom(field) do case safe_to_existing_atom(field) do
{:ok, atom} -> MvWeb.Translations.MemberFields.label(atom) {:ok, atom} -> MemberFields.label(atom)
:error -> fallback_label(field) :error -> fallback_label(field)
end end
end end

View file

@ -115,7 +115,7 @@ defmodule MvWeb.ContributionTypeLive.Index do
<div class="prose prose-sm max-w-none"> <div class="prose prose-sm max-w-none">
<p> <p>
{gettext( {gettext(
"Contribution types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation." "Contribution types define different membership fee structures. Each type has a fixed cycle (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
)} )}
</p> </p>
<ul> <ul>

View file

@ -26,12 +26,12 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
type="button" type="button"
phx-click="cancel" phx-click="cancel"
phx-target={@myself} phx-target={@myself}
aria-label={gettext("Back to custom field overview")} aria-label={gettext("Back to settings")}
> >
<.icon name="hero-arrow-left" class="w-4 h-4" /> <.icon name="hero-arrow-left" class="w-4 h-4" />
</.button> </.button>
<h3 class="card-title"> <h3 class="card-title">
{if @custom_field, do: gettext("Edit Custom Field"), else: gettext("New Custom Field")} {if @custom_field, do: gettext("Edit Data Field"), else: gettext("New Data Field")}
</h3> </h3>
</div> </div>
@ -66,7 +66,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
{gettext("Cancel")} {gettext("Cancel")}
</.button> </.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary"> <.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Custom Field")} {gettext("Save Data Field")}
</.button> </.button>
</div> </div>
</.form> </.form>
@ -91,7 +91,9 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
@impl true @impl true
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do def handle_event("save", %{"custom_field" => custom_field_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: custom_field_params) do actor = MvWeb.LiveHelpers.current_actor(socket)
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, custom_field_params, actor) do
{:ok, custom_field} -> {:ok, custom_field} ->
action = action =
case socket.assigns.form.source.type do case socket.assigns.form.source.type do

View file

@ -17,8 +17,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1) assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1)
~H""" ~H"""
<div id={@id}> <div id={@id} class="mt-8">
<.form_section title={gettext("Custom Fields")}>
<div class="flex"> <div class="flex">
<p class="text-sm text-base-content/70"> <p class="text-sm text-base-content/70">
{gettext("These will appear in addition to other data when adding new members.")} {gettext("These will appear in addition to other data when adding new members.")}
@ -30,7 +29,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
phx-click="new_custom_field" phx-click="new_custom_field"
phx-target={@myself} phx-target={@myself}
> >
<.icon name="hero-plus" /> {gettext("New Custom Field")} <.icon name="hero-plus" /> {gettext("New Data Field")}
</.button> </.button>
</div> </div>
</div> </div>
@ -68,6 +67,19 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
{custom_field.description} {custom_field.description}
</:col> </:col>
<:col
:let={{_id, custom_field}}
label={gettext("Required")}
class="max-w-[9.375rem] text-center"
>
<span :if={custom_field.required} class="text-base-content font-semibold">
{gettext("Required")}
</span>
<span :if={!custom_field.required} class="text-base-content/70">
{gettext("Optional")}
</span>
</:col>
<:col <:col
:let={{_id, custom_field}} :let={{_id, custom_field}}
label={gettext("Show in overview")} label={gettext("Show in overview")}
@ -90,9 +102,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
</:action> </:action>
<:action :let={{_id, custom_field}}> <:action :let={{_id, custom_field}}>
<.link phx-click={ <.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)}>
JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)
}>
{gettext("Delete")} {gettext("Delete")}
</.link> </.link>
</:action> </:action>
@ -101,7 +111,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
<%!-- Delete Confirmation Modal --%> <%!-- Delete Confirmation Modal --%>
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open"> <dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
<div class="modal-box"> <div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Delete Custom Field")}</h3> <h3 class="text-lg font-bold">{gettext("Delete Data Field")}</h3>
<div class="py-4 space-y-4"> <div class="py-4 space-y-4">
<div class="alert alert-warning"> <div class="alert alert-warning">
@ -162,13 +172,15 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
</div> </div>
</div> </div>
</dialog> </dialog>
</.form_section>
</div> </div>
""" """
end end
@impl true @impl true
def update(assigns, socket) do def update(assigns, socket) do
# Track previous show_form state to detect when form is closed
previous_show_form = Map.get(socket.assigns, :show_form, false)
# If show_form is explicitly provided in assigns, reset editing state # If show_form is explicitly provided in assigns, reset editing state
socket = socket =
if Map.has_key?(assigns, :show_form) and assigns.show_form == false do if Map.has_key?(assigns, :show_form) and assigns.show_form == false do
@ -179,6 +191,13 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
socket socket
end end
# Detect when form is closed (show_form changes from true to false)
new_show_form = Map.get(assigns, :show_form, false)
if previous_show_form and not new_show_form do
send(self(), {:editing_section_changed, nil})
end
{:ok, {:ok,
socket socket
|> assign(assigns) |> assign(assigns)
@ -193,6 +212,11 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
@impl true @impl true
def handle_event("new_custom_field", _params, socket) do def handle_event("new_custom_field", _params, socket) do
# Only send event if form was not already open
if not socket.assigns[:show_form] do
send(self(), {:editing_section_changed, :custom_fields})
end
{:noreply, {:noreply,
socket socket
|> assign(:show_form, true) |> assign(:show_form, true)
@ -204,6 +228,11 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
def handle_event("edit_custom_field", %{"id" => id}, socket) do def handle_event("edit_custom_field", %{"id" => id}, socket) do
custom_field = Ash.get!(Mv.Membership.CustomField, id) custom_field = Ash.get!(Mv.Membership.CustomField, id)
# Only send event if form was not already open
if not socket.assigns[:show_form] do
send(self(), {:editing_section_changed, :custom_fields})
end
{:noreply, {:noreply,
socket socket
|> assign(:show_form, true) |> assign(:show_form, true)

View file

@ -32,6 +32,9 @@ defmodule MvWeb.CustomFieldValueLive.Form do
""" """
use MvWeb, :live_view use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -172,8 +175,9 @@ defmodule MvWeb.CustomFieldValueLive.Form do
page_title = action <> " " <> "Custom field value" page_title = action <> " " <> "Custom field value"
# Load all CustomFields and Members for the selection fields # Load all CustomFields and Members for the selection fields
custom_fields = Ash.read!(Mv.Membership.CustomField) actor = current_actor(socket)
members = Ash.read!(Mv.Membership.Member) custom_fields = Ash.read!(Mv.Membership.CustomField, actor: actor)
members = Ash.read!(Mv.Membership.Member, actor: actor)
{:ok, {:ok,
socket socket
@ -224,7 +228,9 @@ defmodule MvWeb.CustomFieldValueLive.Form do
custom_field_value_params custom_field_value_params
end end
case AshPhoenix.Form.submit(socket.assigns.form, params: updated_params) do actor = current_actor(socket)
case submit_form(socket.assigns.form, updated_params, actor) do
{:ok, custom_field_value} -> {:ok, custom_field_value} ->
notify_parent({:saved, custom_field_value}) notify_parent({:saved, custom_field_value})

View file

@ -23,6 +23,9 @@ defmodule MvWeb.CustomFieldValueLive.Index do
""" """
use MvWeb, :live_view use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -70,17 +73,85 @@ defmodule MvWeb.CustomFieldValueLive.Index do
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
actor = current_actor(socket)
# Early return if no actor (prevents exceptions in unauthenticated tests)
if is_nil(actor) do
{:ok, {:ok,
socket socket
|> assign(:page_title, "Listing Custom field values") |> assign(:page_title, "Listing Custom field values")
|> stream(:custom_field_values, Ash.read!(Mv.Membership.CustomFieldValue))} |> stream(:custom_field_values, [])}
else
case Ash.read(Mv.Membership.CustomFieldValue, actor: actor) do
{:ok, custom_field_values} ->
{:ok,
socket
|> assign(:page_title, "Listing Custom field values")
|> stream(:custom_field_values, custom_field_values)}
{:error, %Ash.Error.Forbidden{}} ->
{:ok,
socket
|> assign(:page_title, "Listing Custom field values")
|> stream(:custom_field_values, [])
|> put_flash(:error, gettext("You do not have permission to view custom field values"))}
{:error, error} ->
{:ok,
socket
|> assign(:page_title, "Listing Custom field values")
|> stream(:custom_field_values, [])
|> put_flash(:error, format_error(error))}
end
end
end end
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
custom_field_value = Ash.get!(Mv.Membership.CustomFieldValue, id) actor = MvWeb.LiveHelpers.current_actor(socket)
Ash.destroy!(custom_field_value)
{:noreply, stream_delete(socket, :custom_field_values, custom_field_value)} case Ash.get(Mv.Membership.CustomFieldValue, id, actor: actor) do
{:ok, custom_field_value} ->
case Ash.destroy(custom_field_value, actor: actor) do
:ok ->
{:noreply,
socket
|> stream_delete(:custom_field_values, custom_field_value)
|> put_flash(:info, gettext("Custom field value deleted successfully"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this custom field value")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply, put_flash(socket, :error, gettext("Custom field value not found"))}
{:error, %Ash.Error.Forbidden{} = _error} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to access this custom field value")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
end
defp format_error(%Ash.Error.Invalid{errors: errors}) do
Enum.map_join(errors, ", ", fn %{message: message} -> message end)
end
defp format_error(error) do
inspect(error)
end end
end end

View file

@ -26,7 +26,7 @@ defmodule MvWeb.CustomFieldValueLive.Show do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.header> <.header>
Custom field value {@custom_field_value.id} Data field value {@custom_field_value.id}
<:subtitle>This is a custom_field_value record from your database.</:subtitle> <:subtitle>This is a custom_field_value record from your database.</:subtitle>
<:actions> <:actions>
@ -62,6 +62,6 @@ defmodule MvWeb.CustomFieldValueLive.Show do
|> assign(:custom_field_value, Ash.get!(Mv.Membership.CustomFieldValue, id))} |> assign(:custom_field_value, Ash.get!(Mv.Membership.CustomFieldValue, id))}
end end
defp page_title(:show), do: "Show Custom field value" defp page_title(:show), do: "Show data field value"
defp page_title(:edit), do: "Edit Custom field value" defp page_title(:edit), do: "Edit data field value"
end end

View file

@ -31,6 +31,7 @@ defmodule MvWeb.GlobalSettingsLive do
socket socket
|> assign(:page_title, gettext("Settings")) |> assign(:page_title, gettext("Settings"))
|> assign(:settings, settings) |> assign(:settings, settings)
|> assign(:active_editing_section, nil)
|> assign_form()} |> assign_form()}
end end
@ -62,11 +63,21 @@ defmodule MvWeb.GlobalSettingsLive do
</.button> </.button>
</.form> </.form>
</.form_section> </.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 --%> <%!-- Custom Fields Section --%>
<.live_component <.live_component
:if={@active_editing_section != :member_fields}
module={MvWeb.CustomFieldLive.IndexComponent} module={MvWeb.CustomFieldLive.IndexComponent}
id="custom-fields-component" id="custom-fields-component"
/> />
</.form_section>
</Layouts.app> </Layouts.app>
""" """
end end
@ -79,7 +90,9 @@ defmodule MvWeb.GlobalSettingsLive do
@impl true @impl true
def handle_event("save", %{"setting" => setting_params}, socket) do def handle_event("save", %{"setting" => setting_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do actor = MvWeb.LiveHelpers.current_actor(socket)
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params, actor) do
{:ok, _updated_settings} -> {:ok, _updated_settings} ->
# Reload settings from database to ensure all dependent data is updated # Reload settings from database to ensure all dependent data is updated
{:ok, fresh_settings} = Membership.get_settings() {:ok, fresh_settings} = Membership.get_settings()
@ -105,12 +118,14 @@ defmodule MvWeb.GlobalSettingsLive do
) )
{:noreply, {:noreply,
put_flash(socket, :info, gettext("Custom field %{action} successfully", action: action))} socket
|> assign(:active_editing_section, nil)
|> put_flash(:info, gettext("Data field %{action} successfully", action: action))}
end end
@impl true @impl true
def handle_info({:custom_field_deleted, _custom_field}, socket) do def handle_info({:custom_field_deleted, _custom_field}, socket) do
{:noreply, put_flash(socket, :info, gettext("Custom field deleted successfully"))} {:noreply, put_flash(socket, :info, gettext("Data field deleted successfully"))}
end end
@impl true @impl true
@ -119,7 +134,7 @@ defmodule MvWeb.GlobalSettingsLive do
put_flash( put_flash(
socket, socket,
:error, :error,
gettext("Failed to delete custom field: %{error}", error: inspect(error)) gettext("Failed to delete data field: %{error}", error: inspect(error))
)} )}
end end
@ -128,6 +143,43 @@ defmodule MvWeb.GlobalSettingsLive do
{:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))} {:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))}
end 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)}
end
defp assign_form(%{assigns: %{settings: settings}} = socket) do defp assign_form(%{assigns: %{settings: settings}} = socket) do
form = form =
AshPhoenix.Form.for_update( AshPhoenix.Form.for_update(

View file

@ -0,0 +1,338 @@
defmodule MvWeb.MemberFieldLive.FormComponent do
@moduledoc """
LiveComponent form for editing member field properties (embedded in settings).
## Features
- Edit member field visibility (show_in_overview)
- Display member field information from Member Resource (read-only)
- Restrict editing for email field (only show_in_overview can be changed)
- Real-time validation
- Updates Settings.member_field_visibility atomically
## Props
- `member_field` - The member field atom to edit (e.g., :first_name, :email)
- `settings` - The current Settings resource
- `on_save` - Callback function to call when form is saved
- `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.
"""
use MvWeb, :live_component
alias Mv.Helpers.TypeParsers
alias Mv.Membership
alias Mv.Membership.Helpers.VisibilityConfig
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(:field_label, MemberFields.label(assigns.member_field))
~H"""
<div id={@id} class="mb-8 border shadow-xl card border-base-300">
<div class="card-body">
<div class="flex items-center gap-4 mb-4">
<.button
type="button"
phx-click="cancel"
phx-target={@myself}
aria-label={gettext("Back to Settings")}
>
<.icon name="hero-arrow-left" class="w-4 h-4" />
</.button>
<h3 class="card-title">
{gettext("Edit Field: %{field}", field: @field_label)}
</h3>
</div>
<.form
for={@form}
id={@id <> "-form"}
phx-change="validate"
phx-submit="save"
phx-target={@myself}
>
<div
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("Name")}
<.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[:name].name}
id={@form[:name].id}
value={@field_label}
disabled
readonly
class="w-full input"
/>
</label>
</fieldset>
</div>
<div
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("Value type")}
<.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[:value_type].name}
id={@form[:value_type].id}
value={FieldTypeFormatter.format(@field_attributes.value_type)}
disabled
readonly
class="w-full input"
/>
</label>
</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"
/>
</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?}
/>
<.input
field={@form[:show_in_overview]}
type="checkbox"
label={gettext("Show in overview")}
/>
<div class="justify-end mt-4 card-actions">
<.button type="button" phx-click="cancel" phx-target={@myself}>
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Field")}
</.button>
</div>
</.form>
</div>
</div>
"""
end
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_form()}
end
@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",
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"])
updated_form =
form
|> Map.put(:value, updated_params)
|> Map.put(:errors, [])
{:noreply, assign(socket, form: updated_form)}
end
@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"])
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(
socket.assigns.settings,
field: field_string,
show_in_overview: show_in_overview
) 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, [
%{field: :show_in_overview, message: format_error(error)}
])
{:noreply, assign(socket, form: form)}
end
end
@impl true
def handle_event("cancel", _params, socket) do
socket.assigns.on_cancel.()
{:noreply, socket}
end
# Helper functions
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)
# 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,
"show_in_overview" => show_in_overview
}
form = to_form(form_data, as: "member_field")
assign(socket, form: form)
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
}
attribute ->
%{
value_type: attribute.type,
description: nil,
required: not attribute.allow_nil?
}
end
end
defp format_error(%Ash.Error.Invalid{} = error) do
Ash.ErrorKind.message(error)
end
defp format_error(error) do
inspect(error)
end
end

View file

@ -0,0 +1,219 @@
defmodule MvWeb.MemberFieldLive.IndexComponent do
@moduledoc """
LiveComponent for managing member field visibility in overview (embedded in settings).
## Features
- List all member fields from Mv.Constants.member_fields()
- Display show_in_overview status as badge (Yes/No)
- Display required status based on actual attribute definitions (allow_nil? false)
- Edit member field properties (expandable form like custom fields)
- Updates Settings.member_field_visibility
"""
use MvWeb, :live_component
alias Ash.Resource.Info
alias Mv.Membership
alias Mv.Membership.Helpers.VisibilityConfig
alias MvWeb.Helpers.FieldTypeFormatter
alias MvWeb.Translations.MemberFields
@impl true
def render(assigns) do
assigns =
assigns
|> assign(:member_fields, get_member_fields_with_visibility(assigns.settings))
|> assign(:required?, &required?/1)
~H"""
<div id={@id}>
<p class="text-sm text-base-content/70 mb-4">
{gettext(
"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."
)}
</p>
<%!-- Show form when editing --%>
<div :if={@show_form} class="mb-8">
<.live_component
module={MvWeb.MemberFieldLive.FormComponent}
id={@form_id}
member_field={@editing_member_field}
settings={@settings}
on_save={
fn member_field, action ->
send(self(), {:member_field_saved, member_field, action})
end
}
on_cancel={fn -> send_update(__MODULE__, id: @id, show_form: false) end}
/>
</div>
<%!-- Hide table when form is visible --%>
<.table
:if={!@show_form}
id="member_fields"
rows={@member_fields}
>
<:col :let={{_field_name, field_data}} label={gettext("Name")}>
{MemberFields.label(field_data.field)}
</:col>
<:col :let={{_field_name, field_data}} label={gettext("Value Type")}>
{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"
>
{gettext("Required")}
</span>
<span :if={!@required?.(field_data.field)} class="text-base-content/70">
{gettext("Optional")}
</span>
</:col>
<:col
:let={{_field_name, field_data}}
label={gettext("Show in overview")}
class="max-w-[9.375rem] text-center"
>
<span :if={field_data.show_in_overview} class="badge badge-success">
{gettext("Yes")}
</span>
<span :if={!field_data.show_in_overview} class="badge badge-ghost">
{gettext("No")}
</span>
</:col>
<:action :let={{_field_name, field_data}}>
<.link
phx-click="edit_member_field"
phx-value-field={Atom.to_string(field_data.field)}
phx-target={@myself}
>
{gettext("Edit")}
</.link>
</:action>
</.table>
</div>
"""
end
@impl true
def update(assigns, socket) do
# Track previous show_form state to detect when form is closed
previous_show_form = Map.get(socket.assigns, :show_form, false)
# If show_form is explicitly provided in assigns, reset editing state
socket =
if Map.has_key?(assigns, :show_form) and assigns.show_form == false do
socket
|> assign(:editing_member_field, nil)
|> assign(:form_id, "member-field-form-new")
else
socket
end
# Detect when form is closed (show_form changes from true to false)
new_show_form = Map.get(assigns, :show_form, false)
if previous_show_form and not new_show_form do
send(self(), {:editing_section_changed, nil})
end
{:ok,
socket
|> assign(assigns)
|> assign_new(:settings, fn -> get_settings() end)
|> assign_new(:show_form, fn -> false end)
|> assign_new(:form_id, fn -> "member-field-form-new" end)
|> assign_new(:editing_member_field, fn -> nil end)}
end
@impl true
def handle_event("edit_member_field", %{"field" => field_string}, socket) do
# Validate that the field is a valid member field before converting to atom
valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
if field_string in valid_fields do
field_atom = String.to_existing_atom(field_string)
# Only send event if form was not already open
if not socket.assigns[:show_form] do
send(self(), {:editing_section_changed, :member_fields})
end
{:noreply,
socket
|> assign(:show_form, true)
|> assign(:editing_member_field, field_atom)
|> assign(:form_id, "member-field-form-#{field_string}")}
else
{:noreply, socket}
end
end
# Helper functions
defp get_settings do
case Membership.get_settings() do
{:ok, settings} ->
settings
{: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: %{}}
end
end
defp get_member_fields_with_visibility(settings) do
member_fields = Mv.Constants.member_fields()
visibility_config = settings.member_field_visibility || %{}
# Normalize visibility config keys to atoms
normalized_config = VisibilityConfig.normalize(visibility_config)
Enum.map(member_fields, fn field ->
show_in_overview = Map.get(normalized_config, field, true)
attribute = Info.attribute(Mv.Membership.Member, field)
%{
field: field,
show_in_overview: show_in_overview,
value_type: (attribute && attribute.type) || :string,
description: nil
}
end)
|> Enum.map(fn field_data ->
{Atom.to_string(field_data.field), field_data}
end)
end
defp format_value_type(field) when is_atom(field) do
case Info.attribute(Mv.Membership.Member, field) do
nil -> FieldTypeFormatter.format(:string)
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

@ -21,6 +21,10 @@ defmodule MvWeb.MemberLive.Form do
""" """
use MvWeb, :live_view use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
alias Mv.MembershipFees alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
@ -172,7 +176,7 @@ defmodule MvWeb.MemberLive.Form do
<select <select
class="select select-bordered w-full" class="select select-bordered w-full"
name={@form[:membership_fee_type_id].name} name={@form[:membership_fee_type_id].name}
phx-change="validate_membership_fee_type" phx-change="validate"
value={@form[:membership_fee_type_id].value || ""} value={@form[:membership_fee_type_id].value || ""}
> >
<option value="">{gettext("None")}</option> <option value="">{gettext("None")}</option>
@ -222,6 +226,8 @@ defmodule MvWeb.MemberLive.Form do
@impl true @impl true
def mount(params, _session, socket) do def mount(params, _session, socket) do
# current_user should be set by on_mount hooks (LiveUserAuth + LiveHelpers)
actor = current_actor(socket)
{:ok, custom_fields} = Mv.Membership.list_custom_fields() {:ok, custom_fields} = Mv.Membership.list_custom_fields()
initial_custom_field_values = initial_custom_field_values =
@ -239,14 +245,14 @@ defmodule MvWeb.MemberLive.Form do
member = member =
case params["id"] do case params["id"] do
nil -> nil nil -> nil
id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type]) id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type], actor: actor)
end end
page_title = page_title =
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member") if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
# Load available membership fee types # Load available membership fee types
available_fee_types = load_available_fee_types(member) available_fee_types = load_available_fee_types(member, actor)
{:ok, {:ok,
socket socket
@ -265,34 +271,42 @@ defmodule MvWeb.MemberLive.Form do
@impl true @impl true
def handle_event("validate", %{"member" => member_params}, socket) do def handle_event("validate", %{"member" => member_params}, socket) do
validated_form = AshPhoenix.Form.validate(socket.assigns.form, member_params) # Merge with existing form values to preserve unchanged fields (especially custom_field_values)
# Extract values directly from form fields to get current state
existing_values = get_existing_form_values(socket.assigns.form)
# Merge existing values with new params (new params take precedence)
merged_params = Map.merge(existing_values, member_params)
validated_form = AshPhoenix.Form.validate(socket.assigns.form, merged_params)
# Check for interval mismatch if membership_fee_type_id changed # Check for interval mismatch if membership_fee_type_id changed
socket = check_interval_change(socket, member_params) socket = check_interval_change(socket, merged_params)
{:noreply, assign(socket, form: validated_form)} {:noreply, assign(socket, form: validated_form)}
end end
def handle_event( def handle_event("save", %{"member" => member_params}, socket) do
"validate_membership_fee_type", try do
%{"member" => %{"membership_fee_type_id" => fee_type_id}}, actor = current_actor(socket)
socket
) do case submit_form(socket.assigns.form, member_params, actor) do
# Same validation as above, but triggered by select change {:ok, member} ->
handle_event("validate", %{"member" => %{"membership_fee_type_id" => fee_type_id}}, socket) handle_save_success(socket, member)
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
rescue
_e in [Ash.Error.Forbidden, Ash.Error.Forbidden.Policy] ->
handle_save_forbidden(socket)
end
end end
def handle_event("save", %{"member" => member_params}, socket) do defp handle_save_success(socket, member) do
case AshPhoenix.Form.submit(socket.assigns.form, params: member_params) do
{:ok, member} ->
notify_parent({:saved, member}) notify_parent({:saved, member})
action = action = get_action_name(socket.assigns.form.source.type)
case socket.assigns.form.source.type do
:create -> gettext("create")
:update -> gettext("update")
other -> to_string(other)
end
socket = socket =
socket socket
@ -300,18 +314,32 @@ defmodule MvWeb.MemberLive.Form do
|> push_navigate(to: return_path(socket.assigns.return_to, member)) |> push_navigate(to: return_path(socket.assigns.return_to, member))
{:noreply, socket} {:noreply, socket}
end
{:error, form} -> defp handle_save_forbidden(socket) do
{:noreply, assign(socket, form: form)} # Handle policy violations that aren't properly displayed in forms
end # AshPhoenix.Form doesn't implement FormData.Error protocol for Forbidden errors
action = get_action_name(socket.assigns.form.source.type)
error_message =
gettext("You do not have permission to %{action} members.", action: action)
{:noreply, put_flash(socket, :error, error_message)}
end end
defp get_action_name(:create), do: gettext("create")
defp get_action_name(:update), do: gettext("update")
defp get_action_name(other), do: to_string(other)
defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
defp assign_form(%{assigns: %{member: member}} = socket) do defp assign_form(%{assigns: assigns} = socket) do
member = assigns.member
actor = assigns[:current_user] || assigns.current_user
form = form =
if member do if member do
{:ok, member} = Ash.load(member, custom_field_values: [:custom_field]) {:ok, member} = Ash.load(member, [custom_field_values: [:custom_field]], actor: actor)
existing_custom_field_values = existing_custom_field_values =
member.custom_field_values member.custom_field_values
@ -342,7 +370,8 @@ defmodule MvWeb.MemberLive.Form do
api: Mv.Membership, api: Mv.Membership,
as: "member", as: "member",
params: params, params: params,
forms: [auto?: true] forms: [auto?: true],
actor: actor
) )
missing_custom_field_values = missing_custom_field_values =
@ -360,7 +389,8 @@ defmodule MvWeb.MemberLive.Form do
api: Mv.Membership, api: Mv.Membership,
as: "member", as: "member",
params: %{"custom_field_values" => socket.assigns[:initial_custom_field_values]}, params: %{"custom_field_values" => socket.assigns[:initial_custom_field_values]},
forms: [auto?: true] forms: [auto?: true],
actor: actor
) )
end end
@ -375,11 +405,11 @@ defmodule MvWeb.MemberLive.Form do
# Helper Functions # Helper Functions
# ----------------------------------------------------------------- # -----------------------------------------------------------------
defp load_available_fee_types(member) do defp load_available_fee_types(member, actor) do
all_types = all_types =
MembershipFeeType MembershipFeeType
|> Ash.Query.sort(name: :asc) |> Ash.Query.sort(name: :asc)
|> Ash.read!(domain: MembershipFees) |> Ash.read!(domain: MembershipFees, actor: actor)
# If member has a fee type, filter to same interval # If member has a fee type, filter to same interval
if member && member.membership_fee_type do if member && member.membership_fee_type do
@ -453,4 +483,167 @@ defmodule MvWeb.MemberLive.Form do
defp custom_field_input_type(:date), do: "date" defp custom_field_input_type(:date), do: "date"
defp custom_field_input_type(:email), do: "email" defp custom_field_input_type(:email), do: "email"
defp custom_field_input_type(_), do: "text" defp custom_field_input_type(_), do: "text"
# -----------------------------------------------------------------
# Helper Functions for Form Value Preservation
# -----------------------------------------------------------------
# Helper to extract existing form values to preserve them when only one field changes
# This ensures custom_field_values and other fields are preserved when only the dropdown changes
defp get_existing_form_values(form) do
%{}
|> extract_form_value(form, :first_name, &to_string/1)
|> extract_form_value(form, :last_name, &to_string/1)
|> extract_form_value(form, :email, &to_string/1)
|> extract_form_value(form, :street, &to_string/1)
|> 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, :join_date, &format_date_value/1)
|> extract_form_value(form, :exit_date, &format_date_value/1)
|> extract_form_value(form, :notes, &to_string/1)
|> extract_form_value(form, :membership_fee_type_id, &to_string/1)
|> extract_form_value(form, :membership_fee_start_date, &format_date_value/1)
|> extract_custom_field_values(form)
end
# Helper to extract a single form field value
defp extract_form_value(acc, form, field, formatter) do
if form[field] && form[field].value do
Map.put(acc, to_string(field), formatter.(form[field].value))
else
acc
end
end
# Extracts custom field values from the form structure
# The form is a Phoenix.HTML.Form with source being AshPhoenix.Form
# Custom field values are in form.source.params["custom_field_values"] as a map
defp extract_custom_field_values(acc, form) do
cfv_params = get_custom_field_values_params(form)
if map_size(cfv_params) > 0 do
custom_field_values = convert_cfv_params_to_list(cfv_params)
Map.put(acc, "custom_field_values", custom_field_values)
else
acc
end
end
# Gets custom_field_values from form params
defp get_custom_field_values_params(form) do
ash_form = form.source
if ash_form && Map.has_key?(ash_form, :params) && ash_form.params["custom_field_values"] do
ash_form.params["custom_field_values"]
else
%{}
end
end
# Converts custom field values map to sorted list
defp convert_cfv_params_to_list(cfv_params) do
cfv_params
|> Map.to_list()
|> Enum.sort_by(&parse_numeric_key/1)
|> Enum.map(&build_custom_field_value/1)
end
# Parses numeric key for sorting
defp parse_numeric_key({key, _}) do
case Integer.parse(key) do
{num, _} -> num
:error -> 999_999
end
end
# Builds a custom field value map from params
defp build_custom_field_value({_key, cfv_map}) do
%{
"custom_field_id" => Map.get(cfv_map, "custom_field_id", ""),
"value" => extract_custom_field_value_from_map(Map.get(cfv_map, "value", %{}))
}
end
# Extracts the value map structure from a custom field value
# Handles both map format and Ash.Union struct format
defp extract_custom_field_value_from_map(%Ash.Union{} = union) do
union_type = Atom.to_string(union.type)
%{
"_union_type" => union_type,
"type" => union_type,
"value" => format_custom_field_value(union.value)
}
end
defp extract_custom_field_value_from_map(value_map) when is_map(value_map) do
union_type = extract_union_type_from_map(value_map)
value = Map.get(value_map, "value") || Map.get(value_map, :value)
%{
"_union_type" => union_type,
"type" => union_type,
"value" => format_custom_field_value(value)
}
end
defp extract_custom_field_value_from_map(_),
do: %{"_union_type" => "", "type" => "", "value" => ""}
# Extracts union type from map, checking various possible locations
defp extract_union_type_from_map(value_map) do
cond do
has_non_empty_string(value_map, "_union_type") ->
Map.get(value_map, "_union_type")
has_non_empty_atom(value_map, :_union_type) ->
to_string(Map.get(value_map, :_union_type))
has_atom_type(value_map) ->
Atom.to_string(Map.get(value_map, :type))
has_string_type(value_map) ->
Map.get(value_map, "type")
true ->
""
end
end
# Helper to check if map has non-empty string value
defp has_non_empty_string(map, key) do
value = Map.get(map, key)
value && value != ""
end
# Helper to check if map has non-empty atom value
defp has_non_empty_atom(map, key) do
value = Map.get(map, key)
value && value != ""
end
# Helper to check if map has atom type
defp has_atom_type(map) do
value = Map.get(map, :type)
value && is_atom(value)
end
# Helper to check if map has string type
defp has_string_type(map) do
value = Map.get(map, "type")
value && is_binary(value)
end
# Formats custom field value based on its type
defp format_custom_field_value(%Date{} = date), do: Date.to_iso8601(date)
defp format_custom_field_value(%Decimal{} = decimal), do: Decimal.to_string(decimal, :normal)
defp format_custom_field_value(value) when is_boolean(value), do: to_string(value)
defp format_custom_field_value(value) when is_binary(value), do: value
defp format_custom_field_value(value), do: to_string(value)
# Formats date value (Date or string) to string
defp format_date_value(%Date{} = date), do: Date.to_iso8601(date)
defp format_date_value(value) when is_binary(value), do: value
defp format_date_value(_), do: ""
end end

View file

@ -27,14 +27,17 @@ defmodule MvWeb.MemberLive.Index do
""" """
use MvWeb, :live_view use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
require Ash.Query require Ash.Query
import Ash.Expr import Ash.Expr
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership alias Mv.Membership
alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.Helpers.DateFormatter alias MvWeb.Helpers.DateFormatter
alias MvWeb.MemberLive.Index.FieldSelection alias MvWeb.MemberLive.Index.FieldSelection
alias MvWeb.MemberLive.Index.FieldVisibility alias MvWeb.MemberLive.Index.FieldVisibility
alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.MembershipFeeStatus
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>") # Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
@ -55,20 +58,21 @@ defmodule MvWeb.MemberLive.Index do
@impl true @impl true
def mount(_params, session, socket) do def mount(_params, session, socket) do
# Load custom fields that should be shown in overview (for display) # Load custom fields that should be shown in overview (for display)
# Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView # Errors in mount are handled by Phoenix LiveView and result in a 500 error page.
# and result in a 500 error page. This is appropriate for LiveViews where errors # This is appropriate for initialization errors that should be visible to the user.
# should be visible to the user rather than silently failing. actor = current_actor(socket)
custom_fields_visible = custom_fields_visible =
Mv.Membership.CustomField Mv.Membership.CustomField
|> Ash.Query.filter(expr(show_in_overview == true)) |> Ash.Query.filter(expr(show_in_overview == true))
|> Ash.Query.sort(name: :asc) |> Ash.Query.sort(name: :asc)
|> Ash.read!() |> Ash.read!(actor: actor)
# Load ALL custom fields for the dropdown (to show all available fields) # Load ALL custom fields for the dropdown (to show all available fields)
all_custom_fields = all_custom_fields =
Mv.Membership.CustomField Mv.Membership.CustomField
|> Ash.Query.sort(name: :asc) |> Ash.Query.sort(name: :asc)
|> Ash.read!() |> Ash.read!(actor: actor)
# Load settings once to avoid N+1 queries # Load settings once to avoid N+1 queries
settings = settings =
@ -130,13 +134,41 @@ defmodule MvWeb.MemberLive.Index do
""" """
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
# Note: Using bang versions (!) - errors will be handled by Phoenix LiveView actor = current_actor(socket)
# This ensures users see error messages if deletion fails (e.g., permission denied)
member = Ash.get!(Mv.Membership.Member, id)
Ash.destroy!(member)
case Ash.get(Mv.Membership.Member, id, actor: actor) do
{:ok, member} ->
case Ash.destroy(member, actor: actor) do
:ok ->
updated_members = Enum.reject(socket.assigns.members, &(&1.id == id)) updated_members = Enum.reject(socket.assigns.members, &(&1.id == id))
{:noreply, assign(socket, :members, updated_members)}
{:noreply,
socket
|> assign(:members, updated_members)
|> put_flash(:info, gettext("Member deleted successfully"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this member")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
{:error, %Ash.Error.Forbidden{} = _error} ->
{:noreply,
put_flash(socket, :error, gettext("You do not have permission to access this member"))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
end end
@impl true @impl true
@ -236,6 +268,24 @@ defmodule MvWeb.MemberLive.Index do
end end
end end
# Helper to format errors for display
defp format_error(%Ash.Error.Invalid{errors: errors}) do
error_messages =
Enum.map(errors, fn error ->
case error do
%{field: field, message: message} -> "#{field}: #{message}"
%{message: message} -> message
_ -> inspect(error)
end
end)
Enum.join(error_messages, ", ")
end
defp format_error(error) do
inspect(error)
end
# ----------------------------------------------------------------- # -----------------------------------------------------------------
# Handle Infos from Child Components # Handle Infos from Child Components
# ----------------------------------------------------------------- # -----------------------------------------------------------------
@ -676,9 +726,9 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.custom_fields_visible socket.assigns.custom_fields_visible
) )
# Note: Using Ash.read! - errors will be handled by Phoenix LiveView # Errors in handle_params are handled by Phoenix LiveView
# This is appropriate for data loading in LiveViews actor = current_actor(socket)
members = Ash.read!(query) members = Ash.read!(query, actor: actor)
# Custom field values are already filtered at the database level in load_custom_field_values/2 # Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore # No need for in-memory filtering anymore

View file

@ -257,6 +257,24 @@
> >
{MvWeb.MemberLive.Index.format_date(member.join_date)} {MvWeb.MemberLive.Index.format_date(member.join_date)}
</:col> </:col>
<:col
:let={member}
:if={:exit_date in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_exit_date}
field={:exit_date}
label={gettext("Exit Date")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{MvWeb.MemberLive.Index.format_date(member.exit_date)}
</:col>
<:col <:col
:let={member} :let={member}
label={gettext("Membership Fee Status")} label={gettext("Membership Fee Status")}

View file

@ -20,6 +20,8 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
3. Default (all fields visible) 3. Default (all fields visible)
""" """
alias Mv.Membership.Helpers.VisibilityConfig
@doc """ @doc """
Gets all available fields for selection. Gets all available fields for selection.
@ -177,13 +179,15 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
# Gets member field visibility from settings # Gets member field visibility from settings
defp get_member_field_visibility_from_settings(settings) do defp get_member_field_visibility_from_settings(settings) do
visibility_config = visibility_config =
normalize_visibility_config(Map.get(settings, :member_field_visibility, %{})) VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
member_fields = Mv.Constants.member_fields() member_fields = Mv.Constants.member_fields()
Enum.reduce(member_fields, %{}, fn field, acc -> Enum.reduce(member_fields, %{}, fn field, acc ->
field_string = Atom.to_string(field) field_string = Atom.to_string(field)
show_in_overview = Map.get(visibility_config, field, true) # exit_date defaults to false (hidden), all other fields default to true
default_visibility = if field == :exit_date, do: false, else: true
show_in_overview = Map.get(visibility_config, field, default_visibility)
Map.put(acc, field_string, show_in_overview) Map.put(acc, field_string, show_in_overview)
end) end)
end end
@ -199,27 +203,6 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
end) end)
end end
# Normalizes visibility config map keys from strings to atoms
defp normalize_visibility_config(config) when is_map(config) do
Enum.reduce(config, %{}, fn
{key, value}, acc when is_atom(key) ->
Map.put(acc, key, value)
{key, value}, acc when is_binary(key) ->
try do
atom_key = String.to_existing_atom(key)
Map.put(acc, atom_key, value)
rescue
ArgumentError -> acc
end
_, acc ->
acc
end)
end
defp normalize_visibility_config(_), do: %{}
# Converts field string to atom (for member fields) or keeps as string (for custom fields) # Converts field string to atom (for member fields) or keeps as string (for custom fields)
defp to_field_identifier(field_string) when is_binary(field_string) do defp to_field_identifier(field_string) when is_binary(field_string) do
if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do

View file

@ -20,6 +20,9 @@ defmodule MvWeb.MemberLive.Show do
""" """
use MvWeb, :live_view use MvWeb, :live_view
import Ash.Query import Ash.Query
import MvWeb.LiveHelpers, only: [current_actor: 1]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
@ -220,6 +223,7 @@ defmodule MvWeb.MemberLive.Show do
module={MvWeb.MemberLive.Show.MembershipFeesComponent} module={MvWeb.MemberLive.Show.MembershipFeesComponent}
id={"membership-fees-#{@member.id}"} id={"membership-fees-#{@member.id}"}
member={@member} member={@member}
current_user={@current_user}
/> />
<% end %> <% end %>
</Layouts.app> </Layouts.app>
@ -233,12 +237,14 @@ defmodule MvWeb.MemberLive.Show do
@impl true @impl true
def handle_params(%{"id" => id}, _, socket) do def handle_params(%{"id" => id}, _, socket) do
actor = current_actor(socket)
# Load custom fields once using assign_new to avoid repeated queries # Load custom fields once using assign_new to avoid repeated queries
socket = socket =
assign_new(socket, :custom_fields, fn -> assign_new(socket, :custom_fields, fn ->
Mv.Membership.CustomField Mv.Membership.CustomField
|> Ash.Query.sort(name: :asc) |> Ash.Query.sort(name: :asc)
|> Ash.read!() |> Ash.read!(actor: actor)
end) end)
query = query =
@ -251,7 +257,7 @@ defmodule MvWeb.MemberLive.Show do
membership_fee_cycles: [:membership_fee_type] membership_fee_cycles: [:membership_fee_type]
]) ])
member = Ash.read_one!(query) member = Ash.read_one!(query, actor: actor)
# Calculate last and current cycle status from loaded cycles # Calculate last and current cycle status from loaded cycles
last_cycle_status = get_last_cycle_status(member) last_cycle_status = get_last_cycle_status(member)

View file

@ -13,8 +13,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
use MvWeb, :live_component use MvWeb, :live_component
require Ash.Query require Ash.Query
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership alias Mv.Membership
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.CycleGenerator alias Mv.MembershipFees.CycleGenerator
@ -390,6 +392,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
@impl true @impl true
def update(assigns, socket) do def update(assigns, socket) do
member = assigns.member member = assigns.member
actor = assigns.current_user
# Load cycles if not already loaded # Load cycles if not already loaded
cycles = cycles =
@ -403,7 +406,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
cycles = Enum.sort_by(cycles, & &1.cycle_start, {:desc, Date}) cycles = Enum.sort_by(cycles, & &1.cycle_start, {:desc, Date})
# Get available fee types (filtered to same interval if member has a type) # Get available fee types (filtered to same interval if member has a type)
available_fee_types = get_available_fee_types(member) available_fee_types = get_available_fee_types(member, actor)
{:ok, {:ok,
socket socket
@ -424,7 +427,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
@impl true @impl true
def handle_event("change_membership_fee_type", %{"value" => ""}, socket) do def handle_event("change_membership_fee_type", %{"value" => ""}, socket) do
# Remove membership fee type # Remove membership fee type
case update_member_fee_type(socket.assigns.member, nil) do actor = current_actor(socket)
case update_member_fee_type(socket.assigns.member, nil, actor) do
{:ok, updated_member} -> {:ok, updated_member} ->
send(self(), {:member_updated, updated_member}) send(self(), {:member_updated, updated_member})
@ -432,7 +437,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
socket socket
|> assign(:member, updated_member) |> assign(:member, updated_member)
|> assign(:cycles, []) |> assign(:cycles, [])
|> assign(:available_fee_types, get_available_fee_types(updated_member)) |> assign(
:available_fee_types,
get_available_fee_types(updated_member, current_actor(socket))
)
|> assign(:interval_warning, nil) |> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type removed"))} |> put_flash(:info, gettext("Membership fee type removed"))}
@ -443,7 +451,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
def handle_event("change_membership_fee_type", %{"value" => fee_type_id}, socket) do def handle_event("change_membership_fee_type", %{"value" => fee_type_id}, socket) do
member = socket.assigns.member member = socket.assigns.member
new_fee_type = Ash.get!(MembershipFeeType, fee_type_id, domain: MembershipFees) actor = current_actor(socket)
new_fee_type = Ash.get!(MembershipFeeType, fee_type_id, domain: MembershipFees, actor: actor)
# Check if interval matches # Check if interval matches
interval_warning = interval_warning =
@ -461,15 +470,22 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
if interval_warning do if interval_warning do
{:noreply, assign(socket, :interval_warning, interval_warning)} {:noreply, assign(socket, :interval_warning, interval_warning)}
else else
case update_member_fee_type(member, fee_type_id) do actor = current_actor(socket)
case update_member_fee_type(member, fee_type_id, actor) do
{:ok, updated_member} -> {:ok, updated_member} ->
# Reload member with cycles # Reload member with cycles
actor = current_actor(socket)
updated_member = updated_member =
updated_member updated_member
|> Ash.load!([ |> Ash.load!(
[
:membership_fee_type, :membership_fee_type,
membership_fee_cycles: [:membership_fee_type] membership_fee_cycles: [:membership_fee_type]
]) ],
actor: actor
)
cycles = cycles =
Enum.sort_by( Enum.sort_by(
@ -484,7 +500,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
socket socket
|> assign(:member, updated_member) |> assign(:member, updated_member)
|> assign(:cycles, cycles) |> assign(:cycles, cycles)
|> assign(:available_fee_types, get_available_fee_types(updated_member)) |> assign(
:available_fee_types,
get_available_fee_types(updated_member, current_actor(socket))
)
|> assign(:interval_warning, nil) |> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))} |> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
@ -505,7 +524,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
:suspended -> :mark_as_suspended :suspended -> :mark_as_suspended
end end
case Ash.update(cycle, action: action, domain: MembershipFees) do actor = current_actor(socket)
case Ash.update(cycle, action: action, domain: MembershipFees, actor: actor) do
{:ok, updated_cycle} -> {:ok, updated_cycle} ->
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle) updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
@ -535,16 +556,22 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
def handle_event("regenerate_cycles", _params, socket) do def handle_event("regenerate_cycles", _params, socket) do
socket = assign(socket, :regenerating, true) socket = assign(socket, :regenerating, true)
member = socket.assigns.member member = socket.assigns.member
actor = current_actor(socket)
case CycleGenerator.generate_cycles_for_member(member.id) do case CycleGenerator.generate_cycles_for_member(member.id, actor: actor) do
{:ok, _new_cycles, _notifications} -> {:ok, _new_cycles, _notifications} ->
# Reload member with cycles # Reload member with cycles
actor = current_actor(socket)
updated_member = updated_member =
member member
|> Ash.load!([ |> Ash.load!(
[
:membership_fee_type, :membership_fee_type,
membership_fee_cycles: [:membership_fee_type] membership_fee_cycles: [:membership_fee_type]
]) ],
actor: actor
)
cycles = cycles =
Enum.sort_by( Enum.sort_by(
@ -574,7 +601,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
cycle = find_cycle(socket.assigns.cycles, cycle_id) cycle = find_cycle(socket.assigns.cycles, cycle_id)
# Load cycle with membership_fee_type for display # Load cycle with membership_fee_type for display
cycle = Ash.load!(cycle, :membership_fee_type) actor = current_actor(socket)
cycle = Ash.load!(cycle, :membership_fee_type, actor: actor)
{:noreply, assign(socket, :editing_cycle, cycle)} {:noreply, assign(socket, :editing_cycle, cycle)}
end end
@ -591,9 +619,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
case Decimal.parse(normalized_amount_str) do case Decimal.parse(normalized_amount_str) do
{amount, _} when is_struct(amount, Decimal) -> {amount, _} when is_struct(amount, Decimal) ->
actor = current_actor(socket)
case cycle case cycle
|> Ash.Changeset.for_update(:update, %{amount: amount}) |> Ash.Changeset.for_update(:update, %{amount: amount})
|> Ash.update(domain: MembershipFees) do |> Ash.update(domain: MembershipFees, actor: actor) do
{:ok, updated_cycle} -> {:ok, updated_cycle} ->
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle) updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
@ -618,7 +648,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
cycle = find_cycle(socket.assigns.cycles, cycle_id) cycle = find_cycle(socket.assigns.cycles, cycle_id)
# Load cycle with membership_fee_type for display # Load cycle with membership_fee_type for display
cycle = Ash.load!(cycle, :membership_fee_type) actor = current_actor(socket)
cycle = Ash.load!(cycle, :membership_fee_type, actor: actor)
{:noreply, assign(socket, :deleting_cycle, cycle)} {:noreply, assign(socket, :deleting_cycle, cycle)}
end end
@ -629,8 +660,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
def handle_event("confirm_delete_cycle", %{"cycle_id" => cycle_id}, socket) do def handle_event("confirm_delete_cycle", %{"cycle_id" => cycle_id}, socket) do
cycle = find_cycle(socket.assigns.cycles, cycle_id) cycle = find_cycle(socket.assigns.cycles, cycle_id)
actor = current_actor(socket)
case Ash.destroy(cycle, domain: MembershipFees) do case Ash.destroy(cycle, domain: MembershipFees, actor: actor) do
:ok -> :ok ->
updated_cycles = Enum.reject(socket.assigns.cycles, &(&1.id == cycle_id)) updated_cycles = Enum.reject(socket.assigns.cycles, &(&1.id == cycle_id))
@ -701,12 +733,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
if deleted_count > 0 do if deleted_count > 0 do
# Reload member to get updated cycles # Reload member to get updated cycles
actor = current_actor(socket)
updated_member = updated_member =
member member
|> Ash.load!([ |> Ash.load!(
[
:membership_fee_type, :membership_fee_type,
membership_fee_cycles: [:membership_fee_type] membership_fee_cycles: [:membership_fee_type]
]) ],
actor: actor
)
updated_cycles = updated_cycles =
Enum.sort_by( Enum.sort_by(
@ -788,15 +825,20 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
membership_fee_type_id: member.membership_fee_type_id membership_fee_type_id: member.membership_fee_type_id
} }
case Ash.create(MembershipFeeCycle, attrs, domain: MembershipFees) do actor = current_actor(socket)
case Ash.create(MembershipFeeCycle, attrs, domain: MembershipFees, actor: actor) do
{:ok, _new_cycle} -> {:ok, _new_cycle} ->
# Reload member with cycles # Reload member with cycles
updated_member = updated_member =
member member
|> Ash.load!([ |> Ash.load!(
[
:membership_fee_type, :membership_fee_type,
membership_fee_cycles: [:membership_fee_type] membership_fee_cycles: [:membership_fee_type]
]) ],
actor: actor
)
cycles = cycles =
Enum.sort_by( Enum.sort_by(
@ -844,11 +886,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
# Helper functions # Helper functions
defp get_available_fee_types(member) do defp get_available_fee_types(member, actor) do
all_types = all_types =
MembershipFeeType MembershipFeeType
|> Ash.Query.sort(name: :asc) |> Ash.Query.sort(name: :asc)
|> Ash.read!() |> Ash.read!(domain: MembershipFees, actor: actor)
# If member has a fee type, filter to same interval # If member has a fee type, filter to same interval
if member.membership_fee_type do if member.membership_fee_type do
@ -860,12 +902,12 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
end end
end end
defp update_member_fee_type(member, fee_type_id) do defp update_member_fee_type(member, fee_type_id, actor) do
attrs = %{membership_fee_type_id: fee_type_id} attrs = %{membership_fee_type_id: fee_type_id}
member member
|> Ash.Changeset.for_update(:update_member, attrs, domain: Membership) |> Ash.Changeset.for_update(:update_member, attrs, domain: Membership)
|> Ash.update(domain: Membership) |> Ash.update(domain: Membership, actor: actor)
end end
defp find_cycle(cycles, cycle_id) do defp find_cycle(cycles, cycle_id) do

View file

@ -63,7 +63,9 @@ defmodule MvWeb.MembershipFeeSettingsLive do
Map.put(params, "include_joining_cycle", false) Map.put(params, "include_joining_cycle", false)
end end
case AshPhoenix.Form.submit(socket.assigns.form, params: normalized_params) do actor = MvWeb.LiveHelpers.current_actor(socket)
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, normalized_params, actor) do
{:ok, updated_settings} -> {:ok, updated_settings} ->
{:noreply, {:noreply,
socket socket

View file

@ -13,11 +13,14 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
""" """
use MvWeb, :live_view use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
require Ash.Query require Ash.Query
alias Mv.Membership.Member
alias Mv.MembershipFees alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
@impl true @impl true
@ -305,7 +308,9 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
if socket.assigns.show_amount_warning do if socket.assigns.show_amount_warning do
{:noreply, put_flash(socket, :error, gettext("Please confirm the amount change first"))} {:noreply, put_flash(socket, :error, gettext("Please confirm the amount change first"))}
else else
case AshPhoenix.Form.submit(socket.assigns.form, params: params) do actor = current_actor(socket)
case submit_form(socket.assigns.form, params, actor) do
{:ok, membership_fee_type} -> {:ok, membership_fee_type} ->
notify_parent({:saved, membership_fee_type}) notify_parent({:saved, membership_fee_type})
@ -380,7 +385,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
@spec return_path(String.t(), MembershipFeeType.t() | nil) :: String.t() @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_types"
@spec get_affected_member_count(String.t()) :: non_neg_integer() @spec get_affected_member_count(String.t(), Mv.Accounts.User.t() | nil) :: non_neg_integer()
# Checks if amount changed and updates socket assigns accordingly # Checks if amount changed and updates socket assigns accordingly
defp check_amount_change(socket, params) do defp check_amount_change(socket, params) do
if socket.assigns.membership_fee_type && Map.has_key?(params, "amount") do if socket.assigns.membership_fee_type && Map.has_key?(params, "amount") do
@ -428,7 +433,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
socket.assigns.affected_member_count socket.assigns.affected_member_count
else else
# Warning being shown for first time, calculate count # Warning being shown for first time, calculate count
get_affected_member_count(socket.assigns.membership_fee_type.id) get_affected_member_count(socket.assigns.membership_fee_type.id, current_actor(socket))
end end
socket socket
@ -446,8 +451,10 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|> assign(:pending_amount, nil) |> assign(:pending_amount, nil)
end end
defp get_affected_member_count(fee_type_id) do defp get_affected_member_count(fee_type_id, actor) do
case Ash.count(Member |> Ash.Query.filter(membership_fee_type_id == ^fee_type_id)) do case Ash.count(Member |> Ash.Query.filter(membership_fee_type_id == ^fee_type_id),
actor: actor
) do
{:ok, count} -> count {:ok, count} -> count
_ -> 0 _ -> 0
end end

View file

@ -14,18 +14,22 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
""" """
use MvWeb, :live_view use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
require Ash.Query require Ash.Query
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership alias Mv.Membership
alias Mv.Membership.Member alias Mv.Membership.Member
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
fee_types = load_membership_fee_types() actor = current_actor(socket)
member_counts = load_member_counts(fee_types) fee_types = load_membership_fee_types(actor)
member_counts = load_member_counts(fee_types, actor)
{:ok, {:ok,
socket socket
@ -115,7 +119,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
phx-value-id={mft.id} phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")} data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-xs text-error" class="btn btn-ghost btn-xs text-error"
aria-label={gettext("Delete membership fee type")} aria-label={gettext("Delete Membership Fee Type")}
> >
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
</button> </button>
@ -129,9 +133,11 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
fee_type = Ash.get!(MembershipFeeType, id, domain: MembershipFees) actor = current_actor(socket)
case Ash.destroy(fee_type, domain: MembershipFees) do case Ash.get(MembershipFeeType, id, domain: MembershipFees, actor: actor) do
{:ok, fee_type} ->
case Ash.destroy(fee_type, domain: MembershipFees, actor: actor) do
:ok -> :ok ->
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id)) updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
updated_counts = Map.delete(socket.assigns.member_counts, id) updated_counts = Map.delete(socket.assigns.member_counts, id)
@ -142,6 +148,29 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|> assign(:member_counts, updated_counts) |> assign(:member_counts, updated_counts)
|> put_flash(:info, gettext("Membership fee type deleted"))} |> 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{} = _error} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to access this membership fee type")
)}
{:error, error} -> {:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))} {:noreply, put_flash(socket, :error, format_error(error))}
end end
@ -149,14 +178,14 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
# Helper functions # Helper functions
defp load_membership_fee_types do defp load_membership_fee_types(actor) do
MembershipFeeType MembershipFeeType
|> Ash.Query.sort(name: :asc) |> Ash.Query.sort(name: :asc)
|> Ash.read!(domain: MembershipFees) |> Ash.read!(domain: MembershipFees, actor: actor)
end end
# Loads all member counts for fee types in a single query to avoid N+1 queries # Loads all member counts for fee types in a single query to avoid N+1 queries
defp load_member_counts(fee_types) do defp load_member_counts(fee_types, actor) do
fee_type_ids = Enum.map(fee_types, & &1.id) fee_type_ids = Enum.map(fee_types, & &1.id)
# Load all members with membership_fee_type_id in a single query # Load all members with membership_fee_type_id in a single query
@ -164,7 +193,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
Member Member
|> Ash.Query.filter(membership_fee_type_id in ^fee_type_ids) |> Ash.Query.filter(membership_fee_type_id in ^fee_type_ids)
|> Ash.Query.select([:membership_fee_type_id]) |> Ash.Query.select([:membership_fee_type_id])
|> Ash.read!(domain: Membership) |> Ash.read!(domain: Membership, actor: actor)
# Group by membership_fee_type_id and count # Group by membership_fee_type_id and count
members members

View file

@ -162,7 +162,9 @@ defmodule MvWeb.RoleLive.Form do
end end
def handle_event("save", %{"role" => role_params}, socket) do def handle_event("save", %{"role" => role_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: role_params) do actor = MvWeb.LiveHelpers.current_actor(socket)
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, role_params, actor) do
{:ok, role} -> {:ok, role} ->
notify_parent({:saved, role}) notify_parent({:saved, role})

View file

@ -118,7 +118,7 @@ defmodule MvWeb.RoleLive.Index do
@spec load_roles(map() | nil) :: [Mv.Authorization.Role.t()] @spec load_roles(map() | nil) :: [Mv.Authorization.Role.t()]
defp load_roles(actor) do defp load_roles(actor) do
opts = if actor, do: [actor: actor], else: [] opts = MvWeb.LiveHelpers.ash_actor_opts(actor)
case Authorization.list_roles(opts) do case Authorization.list_roles(opts) do
{:ok, roles} -> Enum.sort_by(roles, & &1.name) {:ok, roles} -> Enum.sort_by(roles, & &1.name)

View file

@ -33,6 +33,9 @@ defmodule MvWeb.UserLive.Form do
""" """
use MvWeb, :live_view use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -258,10 +261,12 @@ defmodule MvWeb.UserLive.Form do
@impl true @impl true
def mount(params, _session, socket) do def mount(params, _session, socket) do
actor = current_actor(socket)
user = user =
case params["id"] do case params["id"] do
nil -> nil nil -> nil
id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member]) id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
end end
action = if is_nil(user), do: gettext("New"), else: gettext("Edit") action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
@ -300,6 +305,7 @@ defmodule MvWeb.UserLive.Form do
{:noreply, socket} {:noreply, socket}
end end
@impl true
def handle_event("validate", %{"user" => user_params}, socket) do def handle_event("validate", %{"user" => user_params}, socket) do
validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params) validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params)
@ -307,7 +313,7 @@ defmodule MvWeb.UserLive.Form do
socket = socket =
if Map.has_key?(user_params, "email") do if Map.has_key?(user_params, "email") do
user_email = user_params["email"] user_email = user_params["email"]
members = load_members_for_linking(user_email, socket.assigns.member_search_query) members = load_members_for_linking(user_email, socket.assigns.member_search_query, socket)
assign(socket, form: validated_form, available_members: members) assign(socket, form: validated_form, available_members: members)
else else
@ -317,62 +323,30 @@ defmodule MvWeb.UserLive.Form do
{:noreply, socket} {:noreply, socket}
end end
@impl true
def handle_event("save", %{"user" => user_params}, socket) do def handle_event("save", %{"user" => user_params}, socket) do
actor = current_actor(socket)
# First save the user without member changes # First save the user without member changes
case AshPhoenix.Form.submit(socket.assigns.form, params: user_params) do case submit_form(socket.assigns.form, user_params, actor) do
{:ok, user} -> {:ok, user} ->
# Then handle member linking/unlinking as a separate step handle_member_linking(socket, user, actor)
result =
cond do
# Selected member ID takes precedence (new link)
socket.assigns.selected_member_id ->
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}})
# Unlink flag is set
socket.assigns[:unlink_member] ->
Mv.Accounts.update_user(user, %{member: nil})
# No changes to member relationship
true ->
{:ok, user}
end
case result do
{:ok, updated_user} ->
notify_parent({:saved, updated_user})
socket =
socket
|> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully")
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
{:noreply, socket}
{:error, error} ->
# Show user-friendly error from member linking/unlinking
error_message = extract_error_message(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to link member: %{error}", error: error_message)
)}
end
{:error, form} -> {:error, form} ->
{:noreply, assign(socket, form: form)} {:noreply, assign(socket, form: form)}
end end
end end
@impl true
def handle_event("show_member_dropdown", _params, socket) do def handle_event("show_member_dropdown", _params, socket) do
{:noreply, assign(socket, show_member_dropdown: true)} {:noreply, assign(socket, show_member_dropdown: true)}
end end
@impl true
def handle_event("hide_member_dropdown", _params, socket) do def handle_event("hide_member_dropdown", _params, socket) do
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)} {:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
end end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
return_if_dropdown_closed(socket, fn -> return_if_dropdown_closed(socket, fn ->
max_index = length(socket.assigns.available_members) - 1 max_index = length(socket.assigns.available_members) - 1
@ -389,6 +363,7 @@ defmodule MvWeb.UserLive.Form do
end) end)
end end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do
return_if_dropdown_closed(socket, fn -> return_if_dropdown_closed(socket, fn ->
current = socket.assigns.focused_member_index current = socket.assigns.focused_member_index
@ -404,23 +379,27 @@ defmodule MvWeb.UserLive.Form do
end) end)
end end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do
return_if_dropdown_closed(socket, fn -> return_if_dropdown_closed(socket, fn ->
select_focused_member(socket) select_focused_member(socket)
end) end)
end end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do
return_if_dropdown_closed(socket, fn -> return_if_dropdown_closed(socket, fn ->
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)} {:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
end) end)
end end
@impl true
def handle_event("member_dropdown_keydown", _params, socket) do def handle_event("member_dropdown_keydown", _params, socket) do
# Ignore other keys # Ignore other keys
{:noreply, socket} {:noreply, socket}
end end
@impl true
def handle_event("search_members", %{"member_search" => query}, socket) do def handle_event("search_members", %{"member_search" => query}, socket) do
socket = socket =
socket socket
@ -432,6 +411,7 @@ defmodule MvWeb.UserLive.Form do
{:noreply, socket} {:noreply, socket}
end end
@impl true
def handle_event("select_member", %{"id" => member_id}, socket) do def handle_event("select_member", %{"id" => member_id}, socket) do
# Find the selected member to get their name # Find the selected member to get their name
selected_member = Enum.find(socket.assigns.available_members, &(&1.id == member_id)) selected_member = Enum.find(socket.assigns.available_members, &(&1.id == member_id))
@ -448,27 +428,82 @@ defmodule MvWeb.UserLive.Form do
|> assign(:selected_member_name, member_name) |> assign(:selected_member_name, member_name)
|> assign(:unlink_member, false) |> assign(:unlink_member, false)
|> assign(:show_member_dropdown, false) |> assign(:show_member_dropdown, false)
|> assign(:member_search_query, member_name) |> assign(:focused_member_index, nil)
|> push_event("set-input-value", %{id: "member-search-input", value: member_name})
{:noreply, socket} {:noreply, socket}
end end
@impl true
def handle_event("unlink_member", _params, socket) do def handle_event("unlink_member", _params, socket) do
# Set flag to unlink member on save
# Clear all member selection state and keep dropdown hidden
socket = socket =
socket socket
|> assign(:unlink_member, true)
|> assign(:selected_member_id, nil) |> assign(:selected_member_id, nil)
|> assign(:selected_member_name, nil) |> assign(:selected_member_name, nil)
|> assign(:member_search_query, "") |> assign(:unlink_member, true)
|> assign(:show_member_dropdown, false) |> assign(:show_member_dropdown, false)
|> load_initial_members() |> assign(:focused_member_index, nil)
{:noreply, socket} {:noreply, socket}
end end
defp handle_member_linking(socket, user, actor) do
result = perform_member_link_action(socket, user, actor)
case result do
{:ok, updated_user} ->
handle_save_success(socket, updated_user)
{:error, error} ->
handle_member_link_error(socket, error)
end
end
defp perform_member_link_action(socket, user, actor) do
cond do
# Selected member ID takes precedence (new link)
socket.assigns.selected_member_id ->
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}},
actor: actor
)
# Unlink flag is set
socket.assigns[:unlink_member] ->
Mv.Accounts.update_user(user, %{member: nil}, actor: actor)
# No changes to member relationship
true ->
{:ok, user}
end
end
defp handle_save_success(socket, updated_user) do
notify_parent({:saved, updated_user})
action = get_action_name(socket.assigns.form.source.type)
socket =
socket
|> put_flash(:info, gettext("User %{action} successfully", action: action))
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
{:noreply, socket}
end
defp get_action_name(:create), do: gettext("created")
defp get_action_name(:update), do: gettext("updated")
defp get_action_name(other), do: to_string(other)
defp handle_member_link_error(socket, error) do
error_message = extract_error_message(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to link member: %{error}", error: error_message)
)}
end
@spec notify_parent(any()) :: any() @spec notify_parent(any()) :: any()
defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
@ -497,18 +532,21 @@ defmodule MvWeb.UserLive.Form do
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() @spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
actor = current_actor(socket)
form = form =
if user do if user do
# For existing users, use admin password action if password fields are shown # For existing users, use admin password action if password fields are shown
action = if show_password_fields, do: :admin_set_password, else: :update_user action = if show_password_fields, do: :admin_set_password, else: :update_user
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user") AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
else else
# For new users, use password registration if password fields are shown # For new users, use password registration if password fields are shown
action = if show_password_fields, do: :register_with_password, else: :create_user action = if show_password_fields, do: :register_with_password, else: :create_user
AshPhoenix.Form.for_create(Mv.Accounts.User, action, AshPhoenix.Form.for_create(Mv.Accounts.User, action,
domain: Mv.Accounts, domain: Mv.Accounts,
as: "user" as: "user",
actor: actor
) )
end end
@ -524,7 +562,7 @@ defmodule MvWeb.UserLive.Form do
user = socket.assigns.user user = socket.assigns.user
user_email = if user, do: user.email, else: nil user_email = if user, do: user.email, else: nil
members = load_members_for_linking(user_email, "") members = load_members_for_linking(user_email, "", socket)
# Dropdown should ALWAYS be hidden initially # Dropdown should ALWAYS be hidden initially
# It will only show when user focuses the input field (show_member_dropdown event) # It will only show when user focuses the input field (show_member_dropdown event)
@ -539,12 +577,15 @@ defmodule MvWeb.UserLive.Form do
user = socket.assigns.user user = socket.assigns.user
user_email = if user, do: user.email, else: nil user_email = if user, do: user.email, else: nil
members = load_members_for_linking(user_email, query) members = load_members_for_linking(user_email, query, socket)
assign(socket, available_members: members) assign(socket, available_members: members)
end end
@spec load_members_for_linking(String.t() | nil, String.t() | nil) :: [Mv.Membership.Member.t()] @spec load_members_for_linking(String.t() | nil, String.t() | nil, Phoenix.LiveView.Socket.t()) ::
defp load_members_for_linking(user_email, search_query) do [
Mv.Membership.Member.t()
]
defp load_members_for_linking(user_email, search_query, socket) do
user_email_str = if user_email, do: to_string(user_email), else: nil user_email_str = if user_email, do: to_string(user_email), else: nil
search_query_str = if search_query && search_query != "", do: search_query, else: nil search_query_str = if search_query && search_query != "", do: search_query, else: nil
@ -555,18 +596,25 @@ defmodule MvWeb.UserLive.Form do
search_query: search_query_str search_query: search_query_str
}) })
case Ash.read(query, domain: Mv.Membership) do actor = current_actor(socket)
{:ok, members} ->
# Apply email match filter if user_email is provided # Early return if no actor (prevents exceptions in unauthenticated tests)
if user_email_str do if is_nil(actor) do
Mv.Membership.Member.filter_by_email_match(members, user_email_str) []
else else
members case Ash.read(query, domain: Mv.Membership, actor: actor) do
{:ok, members} -> apply_email_filter(members, user_email_str)
{:error, _} -> []
end
end
end end
{:error, _} -> @spec apply_email_filter([Mv.Membership.Member.t()], String.t() | nil) ::
[] [Mv.Membership.Member.t()]
end defp apply_email_filter(members, nil), do: members
defp apply_email_filter(members, user_email_str) when is_binary(user_email_str) do
Mv.Membership.Member.filter_by_email_match(members, user_email_str)
end end
# Extract user-friendly error message from Ash.Error # Extract user-friendly error message from Ash.Error
@ -576,10 +624,10 @@ defmodule MvWeb.UserLive.Form do
case List.first(errors) do case List.first(errors) do
%{message: message} when is_binary(message) -> message %{message: message} when is_binary(message) -> message
%{field: field, message: message} -> "#{field}: #{message}" %{field: field, message: message} -> "#{field}: #{message}"
_ -> "Unknown error" _ -> gettext("Unknown error")
end end
end end
defp extract_error_message(error) when is_binary(error), do: error defp extract_error_message(error) when is_binary(error), do: error
defp extract_error_message(_), do: "Unknown error" defp extract_error_message(_), do: gettext("Unknown error")
end end

View file

@ -23,9 +23,13 @@ defmodule MvWeb.UserLive.Index do
use MvWeb, :live_view use MvWeb, :live_view
import MvWeb.TableComponents import MvWeb.TableComponents
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts, load: [:member]) actor = current_actor(socket)
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts, load: [:member], actor: actor)
sorted = Enum.sort_by(users, & &1.email) sorted = Enum.sort_by(users, & &1.email)
{:ok, {:ok,
@ -39,11 +43,41 @@ defmodule MvWeb.UserLive.Index do
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts) actor = current_actor(socket)
Ash.destroy!(user, domain: Mv.Accounts)
case Ash.get(Mv.Accounts.User, id, domain: Mv.Accounts, actor: actor) do
{:ok, user} ->
case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do
:ok ->
updated_users = Enum.reject(socket.assigns.users, &(&1.id == id)) updated_users = Enum.reject(socket.assigns.users, &(&1.id == id))
{:noreply, assign(socket, :users, updated_users)}
{:noreply,
socket
|> assign(:users, updated_users)
|> put_flash(:info, gettext("User deleted successfully"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this user")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply, put_flash(socket, :error, gettext("User not found"))}
{:error, %Ash.Error.Forbidden{} = _error} ->
{:noreply,
put_flash(socket, :error, gettext("You do not have permission to access this user"))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
end end
# Selects one user in the list of users # Selects one user in the list of users
@ -104,4 +138,12 @@ defmodule MvWeb.UserLive.Index do
defp toggle_order(:desc), do: :asc defp toggle_order(:desc), do: :asc
defp sort_fun(:asc), do: &<=/2 defp sort_fun(:asc), do: &<=/2
defp sort_fun(:desc), do: &>=/2 defp sort_fun(:desc), do: &>=/2
defp format_error(%Ash.Error.Invalid{errors: errors}) do
Enum.map_join(errors, ", ", fn %{message: message} -> message end)
end
defp format_error(error) do
inspect(error)
end
end end

View file

@ -26,6 +26,9 @@ defmodule MvWeb.UserLive.Show do
""" """
use MvWeb, :live_view use MvWeb, :live_view
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
import MvWeb.LiveHelpers, only: [current_actor: 1]
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -70,7 +73,8 @@ defmodule MvWeb.UserLive.Show do
@impl true @impl true
def mount(%{"id" => id}, _session, socket) do def mount(%{"id" => id}, _session, socket) do
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member]) actor = current_actor(socket)
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
{:ok, {:ok,
socket socket

View file

@ -59,4 +59,53 @@ defmodule MvWeb.LiveHelpers do
user user
end end
end end
@doc """
Helper function to get the current actor (user) from socket assigns.
Provides consistent access pattern across all LiveViews.
Returns nil if no current_user is present.
## Examples
actor = current_actor(socket)
members = Membership.list_members!(actor: actor)
"""
@spec current_actor(Phoenix.LiveView.Socket.t()) :: Mv.Accounts.User.t() | nil
def current_actor(socket) do
socket.assigns[:current_user] || socket.assigns.current_user
end
@doc """
Converts an actor to Ash options list for authorization.
Returns empty list if actor is nil.
Delegates to `Mv.Helpers.ash_actor_opts/1` for consistency across the application.
## Examples
opts = ash_actor_opts(actor)
Ash.read(query, opts)
"""
@spec ash_actor_opts(Mv.Accounts.User.t() | nil) :: keyword()
defdelegate ash_actor_opts(actor), to: Mv.Helpers
@doc """
Submits an AshPhoenix form with consistent actor handling.
This wrapper ensures that actor is always passed via `action_opts`
in a consistent manner across all LiveViews.
## Examples
case submit_form(form, params, actor) do
{:ok, resource} -> # success
{:error, form} -> # validation errors
end
"""
@spec submit_form(AshPhoenix.Form.t(), map(), Mv.Accounts.User.t() | nil) ::
{:ok, Ash.Resource.t()} | {:error, AshPhoenix.Form.t()}
def submit_form(form, params, actor) do
AshPhoenix.Form.submit(form, params: params, action_opts: ash_actor_opts(actor))
end
end end

View file

@ -27,6 +27,7 @@ defmodule MvWeb.Translations.MemberFields do
def label(:street), do: gettext("Street") def label(:street), do: gettext("Street")
def label(:house_number), do: gettext("House Number") def label(:house_number), do: gettext("House Number")
def label(:postal_code), do: gettext("Postal Code") def label(:postal_code), do: gettext("Postal Code")
def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date")
# Fallback for unknown fields # Fallback for unknown fields
def label(field) do def label(field) do

View file

@ -76,6 +76,7 @@ defmodule Mv.MixProject do
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false}, {:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:picosat_elixir, "~> 0.1", only: [:dev, :test]},
{:ecto_commons, "~> 0.3"}, {:ecto_commons, "~> 0.3"},
{:slugify, "~> 1.3"} {:slugify, "~> 1.3"}
] ]

View file

@ -60,6 +60,7 @@
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"}, "postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,7 @@
## to merge POT files into PO files. ## to merge POT files into PO files.
msgid "" msgid ""
msgstr "" msgstr ""
"Language: en\n" "Language: de\n"
## From Ecto.Changeset.cast/4 ## From Ecto.Changeset.cast/4
msgid "can't be blank" msgid "can't be blank"

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -162,6 +162,17 @@ if admin_role do
|> Ash.update!() |> Ash.update!()
end end
# Load admin user with role for use as actor in member operations
# This ensures all member operations have proper authorization
# If admin role creation failed, we cannot proceed with member operations
admin_user_with_role =
if admin_role do
admin_user
|> Ash.load!(:role)
else
raise "Failed to create or find admin role. Cannot proceed with member seeding."
end
# Load all membership fee types for assignment # Load all membership fee types for assignment
# Sort by name to ensure deterministic order # Sort by name to ensure deterministic order
all_fee_types = all_fee_types =
@ -236,7 +247,8 @@ Enum.each(member_attrs_list, fn member_attrs ->
member = member =
Membership.create_member!(member_attrs_without_fee_type, Membership.create_member!(member_attrs_without_fee_type,
upsert?: true, upsert?: true,
upsert_identity: :unique_email upsert_identity: :unique_email,
actor: admin_user_with_role
) )
# Only set membership_fee_type_id if member doesn't have one yet (idempotent) # Only set membership_fee_type_id if member doesn't have one yet (idempotent)
@ -247,7 +259,8 @@ Enum.each(member_attrs_list, fn member_attrs ->
|> Ash.Changeset.for_update(:update_member, %{ |> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: member_attrs_without_status.membership_fee_type_id membership_fee_type_id: member_attrs_without_status.membership_fee_type_id
}) })
|> Ash.update!() |> Ash.Changeset.put_context(:actor, admin_user_with_role)
|> Ash.update!(actor: admin_user_with_role)
else else
member member
end end
@ -264,7 +277,10 @@ Enum.each(member_attrs_list, fn member_attrs ->
if Enum.empty?(member_with_cycles.membership_fee_cycles) do if Enum.empty?(member_with_cycles.membership_fee_cycles) do
# Generate cycles # Generate cycles
{:ok, new_cycles, _notifications} = {:ok, new_cycles, _notifications} =
CycleGenerator.generate_cycles_for_member(final_member.id, skip_lock?: true) CycleGenerator.generate_cycles_for_member(final_member.id,
skip_lock?: true,
actor: admin_user_with_role
)
new_cycles new_cycles
else else
@ -299,7 +315,7 @@ Enum.each(member_attrs_list, fn member_attrs ->
if cycle.status != status do if cycle.status != status do
cycle cycle
|> Ash.Changeset.for_update(:update, %{status: status}) |> Ash.Changeset.for_update(:update, %{status: status})
|> Ash.update!() |> Ash.update!(actor: admin_user_with_role)
end end
end) end)
end end
@ -371,13 +387,15 @@ Enum.with_index(linked_members)
Membership.create_member!( Membership.create_member!(
Map.put(member_attrs_without_fee_type, :user, %{id: user.id}), Map.put(member_attrs_without_fee_type, :user, %{id: user.id}),
upsert?: true, upsert?: true,
upsert_identity: :unique_email upsert_identity: :unique_email,
actor: admin_user_with_role
) )
else else
# User already has a member, just create the member without linking - use upsert to prevent duplicates # User already has a member, just create the member without linking - use upsert to prevent duplicates
Membership.create_member!(member_attrs_without_fee_type, Membership.create_member!(member_attrs_without_fee_type,
upsert?: true, upsert?: true,
upsert_identity: :unique_email upsert_identity: :unique_email,
actor: admin_user_with_role
) )
end end
@ -391,7 +409,7 @@ Enum.with_index(linked_members)
member member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id}) |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!() |> Ash.update!(actor: admin_user_with_role)
else else
member member
end end
@ -408,7 +426,10 @@ Enum.with_index(linked_members)
if Enum.empty?(member_with_cycles.membership_fee_cycles) do if Enum.empty?(member_with_cycles.membership_fee_cycles) do
# Generate cycles # Generate cycles
{:ok, new_cycles, _notifications} = {:ok, new_cycles, _notifications} =
CycleGenerator.generate_cycles_for_member(final_member.id, skip_lock?: true) CycleGenerator.generate_cycles_for_member(final_member.id,
skip_lock?: true,
actor: admin_user_with_role
)
new_cycles new_cycles
else else
@ -435,7 +456,7 @@ Enum.with_index(linked_members)
end) end)
# Create sample custom field values for some members # Create sample custom field values for some members
all_members = Ash.read!(Membership.Member) all_members = Ash.read!(Membership.Member, actor: admin_user_with_role)
all_custom_fields = Ash.read!(Membership.CustomField) all_custom_fields = Ash.read!(Membership.CustomField)
# Helper function to find custom field by name # Helper function to find custom field by name
@ -463,7 +484,11 @@ if hans = find_member.("hans.mueller@example.de") do
custom_field_id: field.id, custom_field_id: field.id,
value: value value: value
}) })
|> Ash.create!(upsert?: true, upsert_identity: :unique_custom_field_per_member) |> Ash.create!(
upsert?: true,
upsert_identity: :unique_custom_field_per_member,
actor: admin_user_with_role
)
end end
end) end)
end end
@ -488,7 +513,11 @@ if greta = find_member.("greta.schmidt@example.de") do
custom_field_id: field.id, custom_field_id: field.id,
value: value value: value
}) })
|> Ash.create!(upsert?: true, upsert_identity: :unique_custom_field_per_member) |> Ash.create!(
upsert?: true,
upsert_identity: :unique_custom_field_per_member,
actor: admin_user_with_role
)
end end
end) end)
end end
@ -514,7 +543,11 @@ if friedrich = find_member.("friedrich.wagner@example.de") do
custom_field_id: field.id, custom_field_id: field.id,
value: value value: value
}) })
|> Ash.create!(upsert?: true, upsert_identity: :unique_custom_field_per_member) |> Ash.create!(
upsert?: true,
upsert_identity: :unique_custom_field_per_member,
actor: admin_user_with_role
)
end end
end) end)
end end
@ -525,10 +558,39 @@ default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
case Membership.get_settings() do case Membership.get_settings() do
{:ok, existing_settings} -> {:ok, existing_settings} ->
# Settings exist, update if club_name is different from env var # Settings exist, update if club_name is different from env var
if existing_settings.club_name != default_club_name do # Also ensure exit_date is set to false by default if not already configured
{:ok, _updated} = updates =
Membership.update_settings(existing_settings, %{club_name: default_club_name}) %{}
|> then(fn acc ->
if existing_settings.club_name != default_club_name,
do: Map.put(acc, :club_name, default_club_name),
else: acc
end)
|> then(fn acc ->
visibility_config = existing_settings.member_field_visibility || %{}
# Ensure exit_date is set to false if not already configured
if not Map.has_key?(visibility_config, "exit_date") and
not Map.has_key?(visibility_config, :exit_date) do
updated_visibility = Map.put(visibility_config, "exit_date", false)
Map.put(acc, :member_field_visibility, updated_visibility)
else
acc
end end
end)
if map_size(updates) > 0 do
{:ok, _updated} = Membership.update_settings(existing_settings, updates)
end
{:ok, nil} ->
# Settings don't exist yet, create with exit_date defaulting to false
{:ok, _settings} =
Membership.Setting
|> Ash.Changeset.for_create(:create, %{
club_name: default_club_name,
member_field_visibility: %{"exit_date" => false}
})
|> Ash.create!()
end end
IO.puts("✅ Seeds completed successfully!") IO.puts("✅ Seeds completed successfully!")

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="12" fill="#4f46e5"/>
<text x="50" y="65" font-family="Arial, sans-serif" font-size="48" font-weight="bold" fill="white" text-anchor="middle">M</text>
</svg>

After

Width:  |  Height:  |  Size: 285 B

View file

@ -0,0 +1,2 @@
Vorname;Nachname;E-Mail;Straße;PLZ;Stadt
Max;Mustermann;max.mustermann@example.com;Hauptstraße;10115;Berlin
1 Vorname Nachname E-Mail Straße PLZ Stadt
2 Max Mustermann max.mustermann@example.com Hauptstraße 10115 Berlin

View file

@ -0,0 +1,2 @@
first_name;last_name;email;street;postal_code;city
John;Doe;john.doe@example.com;Main Street;12345;Berlin
1 first_name last_name email street postal_code city
2 John Doe john.doe@example.com Main Street 12345 Berlin

View file

@ -13,14 +13,17 @@ defmodule Mv.Membership.MemberFieldVisibilityTest do
alias Mv.Membership.Member alias Mv.Membership.Member
describe "show_in_overview?/1" do describe "show_in_overview?/1" do
test "returns true for all member fields by default" do test "returns true for all member fields by default, except exit_date" do
# When no settings exist or member_field_visibility is not configured # When no settings exist or member_field_visibility is not configured
# Test with fields from constants # Test with fields from constants
# Note: exit_date defaults to false (hidden) by design
member_fields = Mv.Constants.member_fields() member_fields = Mv.Constants.member_fields()
Enum.each(member_fields, fn field -> Enum.each(member_fields, fn field ->
assert Member.show_in_overview?(field) == true, expected_visibility = if field == :exit_date, do: false, else: true
"Field #{field} should be visible by default"
assert Member.show_in_overview?(field) == expected_visibility,
"Field #{field} should be #{if expected_visibility, do: "visible", else: "hidden"} by default"
end) end)
end end
@ -77,4 +80,72 @@ defmodule Mv.Membership.MemberFieldVisibilityTest do
end) end)
end end
end end
describe "update_single_member_field_visibility/3" do
test "atomically updates a single field in member_field_visibility" do
{:ok, settings} = Mv.Membership.get_settings()
field_string = "street"
# Update single field
{:ok, updated_settings} =
Mv.Membership.update_single_member_field_visibility(
settings,
field: field_string,
show_in_overview: false
)
# Verify the field was updated
assert updated_settings.member_field_visibility[field_string] == false
# Verify other fields are not affected
other_fields =
Mv.Constants.member_fields()
|> Enum.reject(&(&1 == String.to_existing_atom(field_string)))
Enum.each(other_fields, fn field ->
field_string = Atom.to_string(field)
# Fields not explicitly set should default to true (except exit_date)
expected = if field == :exit_date, do: false, else: true
assert Map.get(updated_settings.member_field_visibility, field_string, expected) ==
expected
end)
end
test "returns error for invalid field name" do
{:ok, settings} = Mv.Membership.get_settings()
assert {:error, %Ash.Error.Invalid{errors: [%{field: :member_field_visibility}]}} =
Mv.Membership.update_single_member_field_visibility(
settings,
field: "invalid_field",
show_in_overview: false
)
end
test "handles concurrent updates atomically" do
{:ok, settings} = Mv.Membership.get_settings()
field1 = "street"
field2 = "house_number"
# Simulate concurrent updates by updating different fields
{:ok, updated1} =
Mv.Membership.update_single_member_field_visibility(
settings,
field: field1,
show_in_overview: false
)
{:ok, updated2} =
Mv.Membership.update_single_member_field_visibility(
updated1,
field: field2,
show_in_overview: true
)
# Both fields should be correctly updated
assert updated2.member_field_visibility[field1] == false
assert updated2.member_field_visibility[field2] == true
end
end
end end

View file

@ -0,0 +1,44 @@
defmodule Mv.Authorization.Checks.HasPermissionFailClosedTest do
@moduledoc """
Regression tests to ensure deny-filter behavior is fail-closed (matches no records).
These tests verify that when HasPermission.auto_filter returns a deny-filter
(e.g., when actor is nil or no permission is found), the filter actually
matches zero records in the database.
This prevents regressions like the previous bug where [id: {:not, {:in, []}}]
was used, which logically evaluates to "NOT (id IN [])" = true for all IDs,
effectively allowing all records instead of denying them.
"""
use Mv.DataCase, async: true
alias Mv.Authorization.Checks.HasPermission
import Mv.Fixtures
test "auto_filter deny-filter matches no records (regression for NOT IN [] allow-all bug)" do
# Arrange: create some members in DB
_m1 = member_fixture()
_m2 = member_fixture()
# Build a minimal authorizer with a stable action type (:read)
authorizer = %Ash.Policy.Authorizer{
resource: Mv.Membership.Member,
action: %{type: :read}
}
# Act: missing actor must yield a deny-all filter (fail-closed)
deny_filter = HasPermission.auto_filter(nil, authorizer, [])
# Apply the returned filter to a real DB query (no authorization involved)
query =
Mv.Membership.Member
|> Ash.Query.new()
|> Ash.Query.filter_input(deny_filter)
{:ok, results} = Ash.read(query, domain: Mv.Membership, authorize?: false)
# Assert: deny-filter must match nothing
assert results == []
end
end

View file

@ -14,16 +14,22 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
alias Mv.Authorization.Checks.HasPermission alias Mv.Authorization.Checks.HasPermission
# Helper to create mock actor with role # Helper to create mock actor with role
defp create_actor_with_role(permission_set_name) do defp create_actor_with_role(permission_set_name, opts \\ []) do
%{ actor = %{
id: "user-#{System.unique_integer([:positive])}", id: "user-#{System.unique_integer([:positive])}",
role: %{permission_set_name: permission_set_name} role: %{permission_set_name: permission_set_name}
} }
# Add member_id if provided (needed for :linked scope tests)
case Keyword.get(opts, :member_id) do
nil -> actor
member_id -> Map.put(actor, :member_id, member_id)
end
end end
describe "Filter Expression Structure - :linked scope" do describe "Filter Expression Structure - :linked scope" do
test "Member filter uses user.id relationship path" do test "Member filter uses actor.member_id (inverse relationship)" do
actor = create_actor_with_role("own_data") actor = create_actor_with_role("own_data", member_id: "member-123")
authorizer = create_authorizer(Mv.Membership.Member, :read) authorizer = create_authorizer(Mv.Membership.Member, :read)
filter = HasPermission.auto_filter(actor, authorizer, []) filter = HasPermission.auto_filter(actor, authorizer, [])
@ -36,8 +42,8 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
assert is_list(filter) or is_map(filter) assert is_list(filter) or is_map(filter)
end end
test "CustomFieldValue filter uses member.user.id relationship path" do test "CustomFieldValue filter uses actor.member_id (via member relationship)" do
actor = create_actor_with_role("own_data") actor = create_actor_with_role("own_data", member_id: "member-123")
authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :read) authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :read)
filter = HasPermission.auto_filter(actor, authorizer, []) filter = HasPermission.auto_filter(actor, authorizer, [])
@ -66,14 +72,15 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
end end
describe "Filter Expression Structure - :all scope" do describe "Filter Expression Structure - :all scope" do
test "Admin can read all members without filter" do test "Admin can read all members without filter (returns expr(true))" do
actor = create_actor_with_role("admin") actor = create_actor_with_role("admin")
authorizer = create_authorizer(Mv.Membership.Member, :read) authorizer = create_authorizer(Mv.Membership.Member, :read)
filter = HasPermission.auto_filter(actor, authorizer, []) filter = HasPermission.auto_filter(actor, authorizer, [])
# :all scope should return nil (no filter needed) # :all scope should return [] (empty keyword list = no filter = allow all records)
assert is_nil(filter) # After auto_filter fix: no longer returns nil, returns [] instead
assert filter == []
end end
end end
@ -81,7 +88,10 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
defp create_authorizer(resource, action) do defp create_authorizer(resource, action) do
%Ash.Policy.Authorizer{ %Ash.Policy.Authorizer{
resource: resource, resource: resource,
subject: %{action: %{name: action}} subject: %{
action: %{type: action},
data: nil
}
} }
end end
end end

View file

@ -13,16 +13,25 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
defp create_authorizer(resource, action) do defp create_authorizer(resource, action) do
%Ash.Policy.Authorizer{ %Ash.Policy.Authorizer{
resource: resource, resource: resource,
subject: %{action: %{name: action}} subject: %{
action: %{type: action},
data: nil
}
} }
end end
# Helper to create actor with role # Helper to create actor with role
defp create_actor(id, permission_set_name) do defp create_actor(id, permission_set_name, opts \\ []) do
%{ actor = %{
id: id, id: id,
role: %{permission_set_name: permission_set_name} role: %{permission_set_name: permission_set_name}
} }
# Add member_id if provided (needed for :linked scope tests)
case Keyword.get(opts, :member_id) do
nil -> actor
member_id -> Map.put(actor, :member_id, member_id)
end
end end
describe "describe/1" do describe "describe/1" do
@ -120,7 +129,7 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
describe "auto_filter/3 - Scope :linked" do describe "auto_filter/3 - Scope :linked" do
test "scope :linked for Member returns user_id filter" do test "scope :linked for Member returns user_id filter" do
user = create_actor("user-123", "own_data") user = create_actor("user-123", "own_data", member_id: "member-456")
authorizer = create_authorizer(Mv.Membership.Member, :read) authorizer = create_authorizer(Mv.Membership.Member, :read)
filter = HasPermission.auto_filter(user, authorizer, []) filter = HasPermission.auto_filter(user, authorizer, [])
@ -130,7 +139,7 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
end end
test "scope :linked for CustomFieldValue returns member.user_id filter" do test "scope :linked for CustomFieldValue returns member.user_id filter" do
user = create_actor("user-123", "own_data") user = create_actor("user-123", "own_data", member_id: "member-456")
authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :update) authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :update)
filter = HasPermission.auto_filter(user, authorizer, []) filter = HasPermission.auto_filter(user, authorizer, [])

View file

@ -0,0 +1,430 @@
defmodule Mv.Membership.MemberPoliciesTest do
@moduledoc """
Tests for Member resource authorization policies.
Tests all 4 permission sets (own_data, read_only, normal_user, admin)
and verifies that policies correctly enforce access control based on
user roles and permission sets.
"""
# async: false because we need database commits to be visible across queries
# in the same test (especially for unlinked members)
use Mv.DataCase, async: false
alias Mv.Membership
alias Mv.Accounts
alias Mv.Authorization
require Ash.Query
# Helper to create a role with a specific permission set
defp create_role_with_permission_set(permission_set_name) do
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
case Authorization.create_role(%{
name: role_name,
description: "Test role for #{permission_set_name}",
permission_set_name: permission_set_name
}) do
{:ok, role} -> role
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
end
end
# Helper to create a user with a specific permission set
# Returns user with role preloaded (required for authorization)
defp create_user_with_permission_set(permission_set_name) do
# Create role with permission set
role = create_role_with_permission_set(permission_set_name)
# Create user
{:ok, user} =
Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "user#{System.unique_integer([:positive])}@example.com",
password: "testpassword123"
})
|> Ash.create()
# Assign role to user
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|> Ash.update()
# Reload user with role preloaded (critical for authorization!)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts)
user_with_role
end
# Helper to create an admin user (for creating test fixtures)
defp create_admin_user do
create_user_with_permission_set("admin")
end
# Helper to create a member linked to a user
defp create_linked_member_for_user(user) do
admin = create_admin_user()
# Create member
# NOTE: We need to ensure the member is actually persisted to the database
# before we try to link it. Ash may delay writes, so we explicitly return the struct.
{:ok, member} =
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Linked",
last_name: "Member",
email: "linked#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: admin, return_notifications?: false)
# Link member to user (User.member_id = member.id)
# We use force_change_attribute because the member already exists and we just
# need to set the foreign key. This avoids the issue where manage_relationship
# tries to query the member without the actor context.
result =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.force_change_attribute(:member_id, member.id)
|> Ash.update(actor: admin, domain: Mv.Accounts, return_notifications?: false)
{:ok, _user} = result
# Return the member struct directly - no need to reload since we just created it
# and we're in the same transaction/sandbox
member
end
# Helper to create an unlinked member (no user relationship)
defp create_unlinked_member do
admin = create_admin_user()
{:ok, member} =
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Unlinked",
last_name: "Member",
email: "unlinked#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: admin)
member
end
describe "own_data permission set (Mitglied)" do
setup do
user = create_user_with_permission_set("own_data")
linked_member = create_linked_member_for_user(user)
unlinked_member = create_unlinked_member()
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
end
test "can read linked member", %{user: user, linked_member: linked_member} do
{:ok, member} =
Ash.get(Membership.Member, linked_member.id, actor: user, domain: Mv.Membership)
assert member.id == linked_member.id
end
test "can update linked member", %{user: user, linked_member: linked_member} do
# Update is allowed via HasPermission check with :linked scope (not via special case)
# The special case policy only applies to :read actions
{:ok, updated_member} =
linked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update(actor: user)
assert updated_member.first_name == "Updated"
end
test "cannot read unlinked member (returns forbidden)", %{
user: user,
unlinked_member: unlinked_member
} do
# Note: With auto_filter policies, when a user tries to read a member that doesn't
# match the filter (id == actor.member_id), Ash returns NotFound, not Forbidden.
# This is the expected behavior - the filter makes the record "invisible" to the user.
assert_raise Ash.Error.Invalid, fn ->
Ash.get!(Membership.Member, unlinked_member.id, actor: user, domain: Mv.Membership)
end
end
test "cannot update unlinked member (returns forbidden)", %{
user: user,
unlinked_member: unlinked_member
} do
assert_raise Ash.Error.Forbidden, fn ->
unlinked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update!(actor: user)
end
end
test "list members returns only linked member", %{user: user, linked_member: linked_member} do
{:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership)
# Should only return the linked member (scope :linked filters)
assert length(members) == 1
assert hd(members).id == linked_member.id
end
test "cannot create member (returns forbidden)", %{user: user} do
assert_raise Ash.Error.Forbidden, fn ->
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "New",
last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create!(actor: user)
end
end
test "cannot destroy member (returns forbidden)", %{user: user, linked_member: linked_member} do
assert_raise Ash.Error.Forbidden, fn ->
Ash.destroy!(linked_member, actor: user)
end
end
end
describe "read_only permission set (Vorstand/Buchhaltung)" do
setup do
user = create_user_with_permission_set("read_only")
linked_member = create_linked_member_for_user(user)
unlinked_member = create_unlinked_member()
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
end
test "can read all members", %{
user: user,
linked_member: linked_member,
unlinked_member: unlinked_member
} do
{:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership)
# Should return all members (scope :all)
member_ids = Enum.map(members, & &1.id)
assert linked_member.id in member_ids
assert unlinked_member.id in member_ids
end
test "can read individual member", %{user: user, unlinked_member: unlinked_member} do
{:ok, member} =
Ash.get(Membership.Member, unlinked_member.id, actor: user, domain: Mv.Membership)
assert member.id == unlinked_member.id
end
test "cannot create member (returns forbidden)", %{user: user} do
assert_raise Ash.Error.Forbidden, fn ->
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "New",
last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create!(actor: user)
end
end
test "cannot update any member (returns forbidden)", %{
user: user,
linked_member: linked_member
} do
assert_raise Ash.Error.Forbidden, fn ->
linked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update!(actor: user)
end
end
test "cannot destroy any member (returns forbidden)", %{
user: user,
unlinked_member: unlinked_member
} do
assert_raise Ash.Error.Forbidden, fn ->
Ash.destroy!(unlinked_member, actor: user)
end
end
end
describe "normal_user permission set (Kassenwart)" do
setup do
user = create_user_with_permission_set("normal_user")
linked_member = create_linked_member_for_user(user)
unlinked_member = create_unlinked_member()
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
end
test "can read all members", %{
user: user,
linked_member: linked_member,
unlinked_member: unlinked_member
} do
{:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership)
# Should return all members (scope :all)
member_ids = Enum.map(members, & &1.id)
assert linked_member.id in member_ids
assert unlinked_member.id in member_ids
end
test "can create member", %{user: user} do
{:ok, member} =
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "New",
last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: user)
assert member.first_name == "New"
end
test "can update any member", %{user: user, unlinked_member: unlinked_member} do
{:ok, updated_member} =
unlinked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update(actor: user)
assert updated_member.first_name == "Updated"
end
test "cannot destroy member (safety - not in permission set)", %{
user: user,
unlinked_member: unlinked_member
} do
assert_raise Ash.Error.Forbidden, fn ->
Ash.destroy!(unlinked_member, actor: user)
end
end
end
describe "admin permission set" do
setup do
user = create_user_with_permission_set("admin")
linked_member = create_linked_member_for_user(user)
unlinked_member = create_unlinked_member()
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
end
test "can read all members", %{
user: user,
linked_member: linked_member,
unlinked_member: unlinked_member
} do
{:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership)
# Should return all members (scope :all)
member_ids = Enum.map(members, & &1.id)
assert linked_member.id in member_ids
assert unlinked_member.id in member_ids
end
test "can create member", %{user: user} do
{:ok, member} =
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "New",
last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: user)
assert member.first_name == "New"
end
test "can update any member", %{user: user, unlinked_member: unlinked_member} do
{:ok, updated_member} =
unlinked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update(actor: user)
assert updated_member.first_name == "Updated"
end
test "can destroy any member", %{user: user, unlinked_member: unlinked_member} do
:ok = Ash.destroy(unlinked_member, actor: user)
# Verify member is deleted
assert {:error, _} = Ash.get(Membership.Member, unlinked_member.id, domain: Mv.Membership)
end
end
describe "special case: user can always READ linked member" do
# Note: The special case policy only applies to :read actions.
# Updates are handled by HasPermission with :linked scope (if permission exists).
test "read_only user can read linked member (via special case bypass)" do
# read_only has Member.read scope :all, but the special case ensures
# users can ALWAYS read their linked member, even if they had no read permission.
# This test verifies the special case works independently of permission sets.
user = create_user_with_permission_set("read_only")
linked_member = create_linked_member_for_user(user)
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
# Should succeed (special case bypass policy for :read takes precedence)
{:ok, member} =
Ash.get(Membership.Member, linked_member.id, actor: user, domain: Mv.Membership)
assert member.id == linked_member.id
end
test "own_data user can read linked member (via special case bypass)" do
# own_data has Member.read scope :linked, but the special case ensures
# users can ALWAYS read their linked member regardless of permission set.
user = create_user_with_permission_set("own_data")
linked_member = create_linked_member_for_user(user)
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
# Should succeed (special case bypass policy for :read takes precedence)
{:ok, member} =
Ash.get(Membership.Member, linked_member.id, actor: user, domain: Mv.Membership)
assert member.id == linked_member.id
end
test "own_data user can update linked member (via HasPermission :linked scope)" do
# Update is NOT handled by special case - it's handled by HasPermission
# with :linked scope. own_data has Member.update scope :linked.
user = create_user_with_permission_set("own_data")
linked_member = create_linked_member_for_user(user)
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
# Should succeed via HasPermission check (not special case)
{:ok, updated_member} =
linked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update(actor: user)
assert updated_member.first_name == "Updated"
end
end
end

View file

@ -1,106 +0,0 @@
defmodule MvWeb.Layouts.NavbarTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
describe "navbar profile section" do
test "renders profile button with correct attributes", %{conn: _conn} do
# Setup: Create a user
user = create_test_user(%{email: "test@example.com"})
html =
render_component(&MvWeb.Layouts.Navbar.navbar/1, %{
current_user: user
})
# Test dropdown structure
assert html =~ "dropdown-content"
assert html =~ "dropdown-end"
assert html =~ ~s(role="button")
# Test profile link
assert html =~ ~s(href="/users/#{user.id}")
assert html =~ "Profil"
end
@tag :skip
# TODO: Implement user initials in navbar avatar - see issue #170
test "shows user initials in avatar", %{conn: _conn} do
# Setup: Create a user with specific email for testing initials
user = create_test_user(%{email: "test.user@example.com"})
html =
render_component(&MvWeb.Layouts.Navbar.navbar/1, %{
current_user: user
})
# Initials from test.user@example.com
assert html =~ "<span>TU</span>"
end
@tag :skip
# TODO: Implement user initials in navbar avatar - see issue #170
test "shows different initials for OIDC user", %{conn: _conn} do
# Setup: Create OIDC user
user_info = %{
"sub" => "oidc_123",
"preferred_username" => "oidc.user@example.com"
}
oauth_tokens = %{
"access_token" => "test_token",
"id_token" => "test_id_token"
}
user =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_rauthy, %{
user_info: user_info,
oauth_tokens: oauth_tokens
})
|> Ash.create!(domain: Mv.Accounts)
html =
render_component(&MvWeb.Layouts.Navbar.navbar/1, %{
current_user: user
})
# Initials from oidc.user@example.com
assert html =~ "<span>OU</span>"
end
test "includes all required navigation items", %{conn: _conn} do
user = create_test_user(%{email: "test@example.com"})
html =
render_component(&MvWeb.Layouts.Navbar.navbar/1, %{
current_user: user
})
# Check for all required menu items
assert html =~ "Profil"
assert html =~ "Settings"
assert html =~ "Logout"
# Check for correct logout path
assert html =~ ~s(href="/sign-out")
end
test "Settings link navigates to global settings page", %{conn: conn} do
user = create_test_user(%{email: "test@example.com"})
conn = conn_with_oidc_user(conn, user)
html =
render_component(&MvWeb.Layouts.Navbar.navbar/1, %{
current_user: user
})
# Check that Settings link exists and points to /settings
assert html =~ "Settings"
assert html =~ ~s(href="/settings") || html =~ ~s(navigate="/settings")
# Verify the link actually works by navigating to it
{:ok, _view, settings_html} = live(conn, ~p"/settings")
assert settings_html =~ "Club Settings"
end
end
end

View file

@ -0,0 +1,850 @@
defmodule MvWeb.Layouts.SidebarTest do
@moduledoc """
Unit tests for the Sidebar component.
Tests cover:
- Basic rendering and structure
- Props handling (current_user, club_name, mobile)
- Menu structure (flat and nested items)
- Footer/Profile section
- Accessibility attributes (ARIA labels, roles)
- CSS classes (DaisyUI conformance)
- Icon rendering
- Conditional visibility
"""
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import MvWeb.Layouts.Sidebar
# =============================================================================
# Helper Functions
# =============================================================================
# Returns assigns for an authenticated user with all required attributes.
defp authenticated_assigns(mobile \\ false) do
%{
current_user: %{id: "user-123", email: "test@example.com"},
club_name: "Test Club",
mobile: mobile
}
end
# Returns assigns for a guest user (not authenticated).
defp guest_assigns(mobile \\ false) do
%{
current_user: nil,
club_name: "Test Club",
mobile: mobile
}
end
# Renders the sidebar component with the given assigns.
defp render_sidebar(assigns) do
render_component(&sidebar/1, assigns)
end
# Checks if the HTML contains a specific CSS class.
defp has_class?(html, class) do
html =~ class
end
# =============================================================================
# Group 1: Basic Rendering (T1.1-T1.3)
# =============================================================================
describe "basic rendering" do
test "T1.1: renders sidebar with main navigation structure" do
html = render_sidebar(authenticated_assigns())
# Check for navigation element with correct ID
assert html =~ ~s(id="main-sidebar")
assert html =~ ~s(aria-label="Main navigation")
# Check for sidebar class
assert has_class?(html, "sidebar")
end
test "T1.2: renders logo correctly" do
html = render_sidebar(authenticated_assigns())
# Check for logo image
assert html =~ ~s(src="/images/mila.svg")
assert html =~ ~s(alt="Mila Logo")
# Check logo has correct size class
assert has_class?(html, "size-8")
end
test "T1.3: renders toggle button with correct attributes (desktop only)" do
html = render_sidebar(authenticated_assigns(false))
# Check for toggle button
assert html =~ ~s(id="sidebar-toggle")
assert html =~ "onclick="
# Check for DaisyUI button classes
assert has_class?(html, "btn")
assert has_class?(html, "btn-ghost")
assert has_class?(html, "btn-sm")
assert has_class?(html, "btn-square")
# Check for both toggle icons (expanded and collapsed)
assert has_class?(html, "sidebar-expanded-icon")
assert has_class?(html, "sidebar-collapsed-icon")
end
test "T1.4: does not render toggle button on mobile" do
html = render_sidebar(authenticated_assigns(true))
# Toggle button should not be rendered on mobile
refute html =~ ~s(id="sidebar-toggle")
end
end
# =============================================================================
# Group 2: Props Handling (T2.1-T2.3)
# =============================================================================
describe "props handling" do
test "T2.1: displays club name when provided" do
assigns = %{
current_user: %{id: "user-1", email: "test@example.com"},
club_name: "My Awesome Club",
mobile: false
}
html = render_sidebar(assigns)
assert html =~ "My Awesome Club"
end
test "T2.2: does not render menu items when current_user is nil" do
html = render_sidebar(guest_assigns())
# Navigation links should not be rendered
refute html =~ ~s(href="/members")
refute html =~ ~s(href="/users")
refute html =~ ~s(href="/settings")
refute html =~ ~s(href="/contribution_types")
# Footer section should not be rendered
refute html =~ "locale-select"
refute html =~ "theme-controller"
end
test "T2.3: renders menu items when current_user is present" do
html = render_sidebar(authenticated_assigns())
# Check for Members link
assert html =~ ~s(href="/members")
# Check for Users link
assert html =~ ~s(href="/users")
# Check for Custom Fields link
assert html =~ ~s(href="/custom_field_values")
# Check for Contributions section
assert html =~ ~s(href="/contribution_types")
assert html =~ ~s(href="/membership_fee_settings")
# Check for Settings link (placeholder)
assert html =~ ~s(href="/settings")
end
test "T2.4: renders sidebar with main-sidebar ID" do
html = render_sidebar(authenticated_assigns(true))
assert html =~ ~s(id="main-sidebar")
end
test "T2.5: renders sidebar with main-sidebar ID on desktop" do
html = render_sidebar(authenticated_assigns(false))
assert html =~ ~s(id="main-sidebar")
end
end
# =============================================================================
# Group 3: Menu Structure (T3.1-T3.3)
# =============================================================================
describe "menu structure" do
test "T3.1: renders flat menu items with icons and labels" do
html = render_sidebar(authenticated_assigns())
# Check for Members link with icon
assert html =~ ~s(href="/members")
assert html =~ "hero-users"
# Check for Users link with icon
assert html =~ ~s(href="/users")
assert html =~ "hero-user-circle"
# Check for Custom Fields link with icon
assert html =~ ~s(href="/custom_field_values")
assert html =~ "hero-rectangle-group"
# Check for Settings link with icon
assert html =~ ~s(href="/settings")
assert html =~ "hero-cog-6-tooth"
# Check for tooltips (data-tip attribute)
assert html =~ "data-tip="
end
test "T3.2: renders nested menu with details element for expanded state" do
html = render_sidebar(authenticated_assigns())
# Check for Contributions section structure with details
assert html =~ "<details"
assert has_class?(html, "expanded-menu-group")
# Check for contribution links
assert html =~ ~s(href="/contribution_types")
assert html =~ ~s(href="/membership_fee_settings")
end
test "T3.3: renders nested menu with dropdown for collapsed state" do
html = render_sidebar(authenticated_assigns())
# Check for collapsed dropdown container
assert has_class?(html, "collapsed-menu-group")
assert has_class?(html, "dropdown")
assert has_class?(html, "dropdown-right")
# Check for dropdown-content
assert has_class?(html, "dropdown-content")
# Check for icon button
assert html =~ "hero-currency-dollar"
assert html =~ ~s(aria-haspopup="menu")
end
end
# =============================================================================
# Group 4: Footer/Profile Section (T4.1-T4.3)
# =============================================================================
describe "footer/profile section" do
test "T4.1: renders footer section when user is authenticated" do
html = render_sidebar(authenticated_assigns())
# Check for footer container with mt-auto
assert has_class?(html, "mt-auto")
# Check for language selector form
assert html =~ ~s(action="/set_locale")
# Check for theme toggle
assert has_class?(html, "theme-controller")
# Check for user menu/avatar
assert has_class?(html, "avatar")
end
test "T4.2: renders language selector with form and options" do
html = render_sidebar(authenticated_assigns())
# Check for form with correct action
assert html =~ ~s(action="/set_locale")
assert html =~ ~s(method="post")
# Check for CSRF token
assert html =~ "_csrf_token"
# Check for select element
assert html =~ ~s(name="locale")
# Check for language options
assert html =~ ~s(value="de")
assert html =~ "Deutsch"
assert html =~ ~s(value="en")
assert html =~ "English"
# Check expanded-only class
assert has_class?(html, "expanded-only")
end
test "T4.3: renders user dropdown with profile and logout links" do
assigns = %{
current_user: %{id: "user-456", email: "test@example.com"},
club_name: "Test Club",
mobile: false
}
html = render_sidebar(assigns)
# Check for dropdown container
assert has_class?(html, "dropdown")
assert has_class?(html, "dropdown-top")
# Check for avatar button
assert html =~ ~s(aria-haspopup="menu")
# Check for profile link (with user ID)
assert html =~ ~s(href="/users/user-456")
# Check for logout link
assert html =~ ~s(href="/sign-out")
# Check for DaisyUI dropdown classes
assert has_class?(html, "dropdown-content")
end
test "T4.4: renders user avatar with placeholder" do
assigns = %{
current_user: %{id: "user-789", email: "alice@example.com"},
club_name: "Test Club",
mobile: false
}
html = render_sidebar(assigns)
# Should have avatar placeholder classes
assert has_class?(html, "avatar")
assert has_class?(html, "placeholder")
assert has_class?(html, "bg-neutral")
assert has_class?(html, "text-neutral-content")
end
end
# =============================================================================
# Group 5: Accessibility Attributes (T5.1-T5.5)
# =============================================================================
describe "accessibility attributes" do
test "T5.1: navigation has correct ARIA label" do
html = render_sidebar(authenticated_assigns())
assert html =~ ~s(aria-label="Main navigation")
end
test "T5.2: toggle button has correct ARIA attributes" do
html = render_sidebar(authenticated_assigns(false))
# Toggle button
assert html =~ ~s(aria-label="Toggle sidebar")
assert html =~ ~s(aria-controls="main-sidebar")
assert html =~ ~s(aria-expanded="true")
end
test "T5.3: menu has correct roles" do
html = render_sidebar(authenticated_assigns())
# Main menu should have menubar role
assert html =~ ~s(role="menubar")
# List items should have role="none"
assert html =~ ~s(role="none")
# Links should have role="menuitem"
assert html =~ ~s(role="menuitem")
end
test "T5.4: nested menu has correct ARIA attributes" do
html = render_sidebar(authenticated_assigns())
# Details summary should have haspopup
assert html =~ ~s(aria-haspopup="true")
# Dropdown button should have haspopup
assert html =~ ~s(aria-haspopup="menu")
# Nested menus should have role="menu"
assert html =~ ~s(role="menu")
end
test "T5.5: icons are hidden from screen readers" do
html = render_sidebar(authenticated_assigns())
# Icons should have aria-hidden="true"
assert html =~ ~s(aria-hidden="true")
end
end
# =============================================================================
# Group 6: CSS Classes - DaisyUI Conformance (T6.1-T6.4)
# =============================================================================
describe "CSS classes - DaisyUI conformance" do
test "T6.1: uses correct DaisyUI menu classes" do
html = render_sidebar(authenticated_assigns())
# menu class on ul
assert has_class?(html, "menu")
end
test "T6.2: uses correct DaisyUI button classes" do
html = render_sidebar(authenticated_assigns(false))
# Button classes
assert has_class?(html, "btn")
assert has_class?(html, "btn-ghost")
assert has_class?(html, "btn-sm")
assert has_class?(html, "btn-square")
end
test "T6.3: uses correct tooltip classes" do
html = render_sidebar(authenticated_assigns())
# Tooltip classes for menu items
assert has_class?(html, "tooltip")
assert has_class?(html, "tooltip-right")
end
test "T6.4: uses correct dropdown classes" do
html = render_sidebar(authenticated_assigns())
# Dropdown classes
assert has_class?(html, "dropdown")
assert has_class?(html, "dropdown-right")
assert has_class?(html, "dropdown-top")
assert has_class?(html, "dropdown-content")
assert has_class?(html, "rounded-box")
end
end
# =============================================================================
# Group 7: Icon Rendering (T7.1-T7.2)
# =============================================================================
describe "icon rendering" do
test "T7.1: renders hero icons for menu items" do
html = render_sidebar(authenticated_assigns())
# Check for hero icons
assert html =~ "hero-users"
assert html =~ "hero-user-circle"
assert html =~ "hero-rectangle-group"
assert html =~ "hero-currency-dollar"
assert html =~ "hero-cog-6-tooth"
assert html =~ "hero-chevron-left"
assert html =~ "hero-chevron-right"
# Icons should have aria-hidden
assert html =~ ~s(aria-hidden="true")
end
test "T7.2: renders icons for theme toggle" do
html = render_sidebar(authenticated_assigns())
# Theme toggle icons (sun and moon)
assert html =~ "hero-sun"
assert html =~ "hero-moon"
end
end
# =============================================================================
# Group 8: State-dependent classes (T8.1-T8.3)
# =============================================================================
describe "state-dependent classes" do
test "T8.1: expanded-only elements have correct CSS class" do
html = render_sidebar(authenticated_assigns())
# Language form should be expanded-only
assert has_class?(html, "expanded-only")
end
test "T8.2: menu-label class is used for text that hides when collapsed" do
html = render_sidebar(authenticated_assigns())
# Menu labels that hide in collapsed state
assert has_class?(html, "menu-label")
end
test "T8.3: toggle button has state icons" do
html = render_sidebar(authenticated_assigns(false))
# Expanded icon
assert has_class?(html, "sidebar-expanded-icon")
# Collapsed icon
assert has_class?(html, "sidebar-collapsed-icon")
end
end
# =============================================================================
# Additional Edge Cases and Validation
# =============================================================================
describe "edge cases" do
test "handles user with minimal attributes" do
assigns = %{
current_user: %{id: "minimal-user", email: "min@test.com"},
club_name: "Minimal Club",
mobile: false
}
html = render_sidebar(assigns)
# Should render without error
assert html =~ "Minimal Club"
assert html =~ ~s(href="/users/minimal-user")
end
test "handles empty club name" do
assigns = %{
current_user: %{id: "user-1", email: "test@test.com"},
club_name: "",
mobile: false
}
html = render_sidebar(assigns)
# Should render without error
assert html =~ ~s(id="main-sidebar")
end
test "sidebar structure is complete with all sections" do
html = render_sidebar(authenticated_assigns())
# Header section
assert html =~ "Mila Logo"
# Navigation section
assert html =~ ~s(role="menubar")
# Footer section
assert html =~ "theme-controller"
# All expected links
expected_links = [
"/members",
"/users",
"/custom_field_values",
"/contribution_types",
"/membership_fee_settings",
"/sign-out"
]
for link <- expected_links do
assert html =~ ~s(href="#{link}"), "Missing link: #{link}"
end
end
end
# =============================================================================
# Component Tests - sidebar_header/1
# =============================================================================
describe "sidebar_header/1" do
test "renders logo with correct size" do
html = render_sidebar(authenticated_assigns())
# Logo is size-8 (32px)
assert html =~ ~s(src="/images/mila.svg")
assert html =~ ~s(alt="Mila Logo")
assert has_class?(html, "size-8")
# Logo is always visible (no conditional classes)
assert html =~ ~s(<img)
end
test "renders club name" do
assigns = %{
current_user: %{id: "user-1", email: "test@example.com"},
club_name: "My Test Club",
mobile: false
}
html = render_sidebar(assigns)
# Club name is present
assert html =~ "My Test Club"
assert has_class?(html, "menu-label")
end
test "renders toggle button for desktop" do
html = render_sidebar(authenticated_assigns(false))
# Toggle button has both icons
assert has_class?(html, "sidebar-expanded-icon")
assert has_class?(html, "sidebar-collapsed-icon")
# Toggle button is not mobile (hidden on mobile)
assert html =~ ~s(id="sidebar-toggle")
assert html =~ ~s(aria-label="Toggle sidebar")
assert html =~ ~s(aria-expanded="true")
end
test "does not render toggle button on mobile" do
html = render_sidebar(authenticated_assigns(true))
# Toggle button should not be rendered on mobile
refute html =~ ~s(id="sidebar-toggle")
end
end
# =============================================================================
# Component Tests - menu_item/1
# =============================================================================
describe "menu_item/1" do
test "renders icon and label" do
html = render_sidebar(authenticated_assigns())
# Icon is visible
assert html =~ "hero-users"
assert html =~ ~s(aria-hidden="true")
# Label is present
assert html =~ "Members"
assert has_class?(html, "menu-label")
end
test "has tooltip with correct text" do
html = render_sidebar(authenticated_assigns())
# data-tip attribute set
assert html =~ ~s(data-tip="Members")
assert has_class?(html, "tooltip")
assert has_class?(html, "tooltip-right")
end
test "has correct link" do
html = render_sidebar(authenticated_assigns())
# navigate attribute correct (rendered as href)
assert html =~ ~s(href="/members")
assert html =~ ~s(role="menuitem")
end
end
# =============================================================================
# Component Tests - menu_group/1
# =============================================================================
describe "menu_group/1" do
test "renders expanded menu group" do
html = render_sidebar(authenticated_assigns())
# details/summary present
assert html =~ "<details"
assert html =~ "<summary"
assert has_class?(html, "expanded-menu-group")
end
test "renders collapsed menu group" do
html = render_sidebar(authenticated_assigns())
# dropdown present
assert has_class?(html, "collapsed-menu-group")
assert has_class?(html, "dropdown")
assert has_class?(html, "dropdown-right")
end
test "renders submenu items" do
html = render_sidebar(authenticated_assigns())
# Inner_block items rendered
assert html =~ ~s(href="/contribution_types")
assert html =~ ~s(href="/membership_fee_settings")
assert html =~ ~s(role="menu")
end
end
# =============================================================================
# Component Tests - sidebar_footer/1
# =============================================================================
describe "sidebar_footer/1" do
test "renders at bottom of sidebar" do
html = render_sidebar(authenticated_assigns())
# mt-auto present
assert has_class?(html, "mt-auto")
end
test "renders theme toggle" do
html = render_sidebar(authenticated_assigns())
# Toggle is always visible
assert has_class?(html, "theme-controller")
assert html =~ "hero-sun"
assert html =~ "hero-moon"
end
test "renders language selector in expanded only" do
html = render_sidebar(authenticated_assigns())
# expanded-only class
assert has_class?(html, "expanded-only")
assert html =~ ~s(action="/set_locale")
end
test "renders user menu with avatar" do
assigns = %{
current_user: %{id: "user-123", email: "alice@example.com"},
club_name: "Test Club",
mobile: false
}
html = render_sidebar(assigns)
# Avatar present
assert has_class?(html, "avatar")
assert has_class?(html, "placeholder")
# First letter correct (A for alice@example.com)
assert html =~ "A"
end
end
# =============================================================================
# Integration Tests - Sidebar State Management
# =============================================================================
describe "sidebar state management" do
test "sidebar starts expanded by default" do
# The data-sidebar-expanded attribute is set in layouts.ex, not in sidebar.ex
# This test verifies the sidebar structure supports the expanded state
html = render_sidebar(authenticated_assigns())
# Sidebar has classes that support expanded state
assert has_class?(html, "menu-label")
assert has_class?(html, "expanded-only")
end
test "toggle button has correct onclick handler" do
html = render_sidebar(authenticated_assigns(false))
# Toggle button has onclick handler
assert html =~ ~r/onclick="toggleSidebar\(\)"/
end
test "no duplicate IDs in layout" do
html = render_sidebar(authenticated_assigns())
# Check that main-sidebar ID appears only once
id_count = html |> String.split(~r/id="main-sidebar"/) |> length() |> Kernel.-(1)
assert id_count == 1, "main-sidebar ID should appear exactly once"
# Check that sidebar-toggle ID appears only once (if present)
if html =~ ~r/id="sidebar-toggle"/ do
toggle_count = html |> String.split(~r/id="sidebar-toggle"/) |> length() |> Kernel.-(1)
assert toggle_count == 1, "sidebar-toggle ID should appear exactly once"
end
end
end
# =============================================================================
# Integration Tests - Mobile Drawer
# =============================================================================
describe "mobile drawer" do
test "mobile header renders on small screens" do
# Mobile header is in layouts.ex, not sidebar.ex
# This test verifies sidebar works correctly with mobile flag
html = render_sidebar(authenticated_assigns(true))
# Sidebar should render without toggle button on mobile
refute html =~ ~s(id="sidebar-toggle")
end
test "drawer overlay is present" do
html = render_sidebar(authenticated_assigns())
# Drawer overlay label for mobile
assert html =~ ~s(for="mobile-drawer")
assert has_class?(html, "drawer-overlay")
assert html =~ ~s(lg:hidden)
end
end
# =============================================================================
# Accessibility Tests - Extended
# =============================================================================
describe "accessibility - extended" do
test "sidebar has aria-label" do
html = render_sidebar(authenticated_assigns())
assert html =~ ~s(aria-label="Main navigation")
end
test "toggle button has aria-label and aria-expanded" do
html = render_sidebar(authenticated_assigns(false))
# aria-label present
assert html =~ ~s(aria-label="Toggle sidebar")
# aria-expanded="true" or "false"
assert html =~ ~s(aria-expanded="true") || html =~ ~s(aria-expanded="false")
end
test "menu items have role attributes" do
html = render_sidebar(authenticated_assigns())
# role="menubar", "menuitem", "menu"
assert html =~ ~s(role="menubar")
assert html =~ ~s(role="menuitem")
assert html =~ ~s(role="menu")
end
test "icons have aria-hidden" do
html = render_sidebar(authenticated_assigns())
# Decorative icons: aria-hidden="true"
# Count occurrences to ensure multiple icons have it
assert html =~ ~s(aria-hidden="true")
end
test "user menu has aria-haspopup" do
html = render_sidebar(authenticated_assigns())
# aria-haspopup="menu"
assert html =~ ~s(aria-haspopup="menu")
end
end
# =============================================================================
# Regression Tests
# =============================================================================
describe "regression tests" do
test "no duplicate profile links" do
html = render_sidebar(authenticated_assigns())
# Only one Profile link in DOM
profile_count = html |> String.split(~s[href="/users/"]) |> length() |> Kernel.-(1)
assert profile_count <= 1, "Should have at most one profile link"
end
test "nested menu has only one hover effect" do
html = render_sidebar(authenticated_assigns())
# Check that menu-group has proper structure
# Both expanded and collapsed versions should be present
assert has_class?(html, "expanded-menu-group")
assert has_class?(html, "collapsed-menu-group")
# Details element should not have duplicate hover classes
# (CSS handles this, but we verify structure)
assert html =~ "<details"
end
test "tooltips only visible when collapsed" do
html = render_sidebar(authenticated_assigns())
# Tooltip classes are present (CSS handles visibility)
assert has_class?(html, "tooltip")
assert has_class?(html, "tooltip-right")
assert html =~ "data-tip="
end
test "user menu dropdown has correct structure" do
html = render_sidebar(authenticated_assigns())
# Dropdown should have proper classes
assert has_class?(html, "dropdown")
assert has_class?(html, "dropdown-top")
assert has_class?(html, "dropdown-content")
# Should have both Profile and Logout links
assert html =~ "sign-out"
end
end
end

View file

@ -1,21 +1,42 @@
defmodule MvWeb.AuthControllerTest do defmodule MvWeb.AuthControllerTest do
use MvWeb.ConnCase, async: true use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
import Phoenix.ConnTest
# Helper to create an unauthenticated conn (preserves sandbox metadata)
defp build_unauthenticated_conn(authenticated_conn) do
# Create new conn but preserve sandbox metadata for database access
new_conn = build_conn()
# Copy sandbox metadata from authenticated conn
if authenticated_conn.private[:ecto_sandbox] do
Plug.Conn.put_private(new_conn, :ecto_sandbox, authenticated_conn.private[:ecto_sandbox])
else
new_conn
end
end
# Basic UI tests # Basic UI tests
test "GET /sign-in shows sign in form", %{conn: conn} do test "GET /sign-in shows sign in form", %{conn: authenticated_conn} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
conn = get(conn, ~p"/sign-in") conn = get(conn, ~p"/sign-in")
assert html_response(conn, 200) =~ "Sign in" assert html_response(conn, 200) =~ "Sign in"
end end
test "GET /sign-out redirects to home", %{conn: conn} do test "GET /sign-out redirects to home", %{conn: authenticated_conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(authenticated_conn)
conn = get(conn, ~p"/sign-out") conn = get(conn, ~p"/sign-out")
assert redirected_to(conn) == ~p"/" assert redirected_to(conn) == ~p"/"
end end
# Password authentication (LiveView) # Password authentication (LiveView)
test "password user can sign in with valid credentials via LiveView", %{conn: conn} do test "password user can sign in with valid credentials via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user = _user =
create_test_user(%{ create_test_user(%{
email: "password@example.com", email: "password@example.com",
@ -35,7 +56,12 @@ defmodule MvWeb.AuthControllerTest do
assert to =~ "/auth/user/password/sign_in_with_token" assert to =~ "/auth/user/password/sign_in_with_token"
end end
test "password user with invalid credentials shows error via LiveView", %{conn: conn} do test "password user with invalid credentials shows error via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user = _user =
create_test_user(%{ create_test_user(%{
email: "test@example.com", email: "test@example.com",
@ -55,7 +81,12 @@ defmodule MvWeb.AuthControllerTest do
assert html =~ "Email or password was incorrect" assert html =~ "Email or password was incorrect"
end end
test "password user with non-existent email shows error via LiveView", %{conn: conn} do test "password user with non-existent email shows error via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
{:ok, view, _html} = live(conn, "/sign-in") {:ok, view, _html} = live(conn, "/sign-in")
html = html =
@ -69,7 +100,10 @@ defmodule MvWeb.AuthControllerTest do
end end
# Registration (LiveView) # Registration (LiveView)
test "user can register with valid credentials via LiveView", %{conn: conn} do test "user can register with valid credentials via LiveView", %{conn: authenticated_conn} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
{:ok, view, _html} = live(conn, "/register") {:ok, view, _html} = live(conn, "/register")
{:error, {:redirect, %{to: to}}} = {:error, {:redirect, %{to: to}}} =
@ -82,7 +116,10 @@ defmodule MvWeb.AuthControllerTest do
assert to =~ "/auth/user/password/sign_in_with_token" assert to =~ "/auth/user/password/sign_in_with_token"
end end
test "registration with existing email shows error via LiveView", %{conn: conn} do test "registration with existing email shows error via LiveView", %{conn: authenticated_conn} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user = _user =
create_test_user(%{ create_test_user(%{
email: "existing@example.com", email: "existing@example.com",
@ -102,7 +139,10 @@ defmodule MvWeb.AuthControllerTest do
assert html =~ "has already been taken" assert html =~ "has already been taken"
end end
test "registration with weak password shows error via LiveView", %{conn: conn} do test "registration with weak password shows error via LiveView", %{conn: authenticated_conn} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
{:ok, view, _html} = live(conn, "/register") {:ok, view, _html} = live(conn, "/register")
html = html =
@ -116,18 +156,27 @@ defmodule MvWeb.AuthControllerTest do
end end
# Access control # Access control
test "unauthenticated user accessing protected route gets redirected to sign-in", %{conn: conn} do test "unauthenticated user accessing protected route gets redirected to sign-in", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
conn = get(conn, ~p"/members") conn = get(conn, ~p"/members")
assert redirected_to(conn) == ~p"/sign-in" assert redirected_to(conn) == ~p"/sign-in"
end end
test "authenticated user can access protected route", %{conn: conn} do test "authenticated user can access protected route", %{conn: authenticated_conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(authenticated_conn)
conn = get(conn, ~p"/members") conn = get(conn, ~p"/members")
assert conn.status == 200 assert conn.status == 200
end end
test "password authenticated user can access protected route via LiveView", %{conn: conn} do test "password authenticated user can access protected route via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user = _user =
create_test_user(%{ create_test_user(%{
email: "auth@example.com", email: "auth@example.com",
@ -150,7 +199,12 @@ defmodule MvWeb.AuthControllerTest do
end end
# Edge cases # Edge cases
test "user with nil oidc_id can still sign in with password via LiveView", %{conn: conn} do test "user with nil oidc_id can still sign in with password via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user = _user =
create_test_user(%{ create_test_user(%{
email: "nil_oidc@example.com", email: "nil_oidc@example.com",
@ -170,7 +224,12 @@ defmodule MvWeb.AuthControllerTest do
assert to =~ "/auth/user/password/sign_in_with_token" assert to =~ "/auth/user/password/sign_in_with_token"
end end
test "user with empty string oidc_id is handled correctly via LiveView", %{conn: conn} do test "user with empty string oidc_id is handled correctly via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user = _user =
create_test_user(%{ create_test_user(%{
email: "empty_oidc@example.com", email: "empty_oidc@example.com",

View file

@ -154,7 +154,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|> render_click() |> render_click()
# Should show success message # Should show success message
assert render(view) =~ "Custom field deleted successfully" assert render(view) =~ "Data field deleted successfully"
# Custom field should be gone from database # Custom field should be gone from database
assert {:error, _} = Ash.get(CustomField, custom_field.id) assert {:error, _} = Ash.get(CustomField, custom_field.id)

View file

@ -64,5 +64,21 @@ defmodule MvWeb.GlobalSettingsLiveTest do
assert html =~ "must be present" assert html =~ "must be present"
end end
test "displays Memberdata section", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
assert html =~ "Memberdata" or html =~ "Member Data"
end
test "displays flash message after member field visibility update", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/settings")
# Simulate member field visibility update
send(view.pid, {:member_field_visibility_updated})
# Check for flash message
assert render(view) =~ "updated" or render(view) =~ "success"
end
end end
end end

View file

@ -0,0 +1,124 @@
defmodule MvWeb.MemberFieldLive.IndexComponentTest do
@moduledoc """
Tests for MemberFieldLive.IndexComponent.
Tests cover:
- Rendering all member fields from Mv.Constants.member_fields()
- Displaying show_in_overview status as badge (Yes/No)
- Displaying required status for required fields (first_name, last_name, email)
- Current status is displayed based on settings.member_field_visibility
- Default status is "Yes" (visible) when not configured in settings
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Membership
setup %{conn: conn} do
user = create_test_user(%{email: "admin@example.com"})
conn = conn_with_oidc_user(conn, user)
{:ok, conn: conn, user: user}
end
describe "rendering" do
test "renders all member fields from Constants", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# Check that all member fields are displayed
member_fields = Mv.Constants.member_fields()
for field <- member_fields do
field_name = String.replace(Atom.to_string(field), "_", " ") |> String.capitalize()
# Field name should appear in the table (either as label or in some form)
assert html =~ field_name or html =~ Atom.to_string(field)
end
end
test "displays show_in_overview status as badge", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# Should have "Show in overview" column header
assert html =~ "Show in overview" or html =~ "Show in Overview"
# Should have badge elements (Yes/No)
assert html =~ "badge" or html =~ "Yes" or html =~ "No"
end
test "displays required status for required fields", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# Required fields: first_name, last_name, email
# Should have "Required" column or indicator
assert html =~ "Required" or html =~ "required"
end
test "shows default status as Yes when not configured", %{conn: conn} do
# Ensure settings have no member_field_visibility configured
{:ok, settings} = Membership.get_settings()
{:ok, _updated} =
Membership.update_settings(settings, %{member_field_visibility: %{}})
{:ok, _view, html} = live(conn, ~p"/settings")
# All fields should show as visible (Yes) by default
# Check for "Yes" badge or similar indicator
assert html =~ "Yes" or html =~ "badge-success"
end
test "shows configured visibility status from settings", %{conn: conn} do
# Configure some fields as hidden
{:ok, settings} = Membership.get_settings()
visibility_config = %{"street" => false, "house_number" => false}
{:ok, _updated} =
Membership.update_member_field_visibility(settings, visibility_config)
{:ok, _view, html} = live(conn, ~p"/settings")
# Street and house_number should show as hidden (No)
# Other fields should show as visible (Yes)
assert html =~ "street" or html =~ "Street"
assert html =~ "house_number" or html =~ "House number"
end
end
describe "required fields" do
test "marks first_name as required", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# first_name should be marked as required
assert html =~ "first_name" or html =~ "First name"
# Should have required indicator
assert html =~ "required" or html =~ "Required"
end
test "marks last_name as required", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# last_name should be marked as required
assert html =~ "last_name" or html =~ "Last name"
# Should have required indicator
assert html =~ "required" or html =~ "Required"
end
test "marks email as required", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# email should be marked as required
assert html =~ "email" or html =~ "Email"
# Should have required indicator
assert html =~ "required" or html =~ "Required"
end
test "does not mark optional fields as required", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# Optional fields should not have required indicator
# Check that street (optional) doesn't have required badge
# This test verifies that only required fields show the indicator
assert html =~ "street" or html =~ "Street"
end
end
end

View file

@ -11,19 +11,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
require Ash.Query require Ash.Query
setup %{conn: conn} do # Use global setup from ConnCase which provides admin user with role
# Create admin user # No custom setup needed
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
authenticated_conn = conn_with_password_user(conn, user)
%{conn: authenticated_conn, user: user}
end
# Helper to create a membership fee type # Helper to create a membership fee type
defp create_fee_type(attrs) do defp create_fee_type(attrs) do
@ -41,7 +30,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
end end
# Helper to create a member # Helper to create a member
defp create_member(attrs) do # Uses admin actor from global setup to ensure authorization
defp create_member(attrs, actor) do
default_attrs = %{ default_attrs = %{
first_name: "Test", first_name: "Test",
last_name: "Member", last_name: "Member",
@ -50,9 +40,11 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
attrs = Map.merge(default_attrs, attrs) attrs = Map.merge(default_attrs, attrs)
opts = if actor, do: [actor: actor], else: []
Member Member
|> Ash.Changeset.for_create(:create_member, attrs) |> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!() |> Ash.create!(opts)
end end
describe "list display" do describe "list display" do
@ -72,12 +64,12 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
assert html =~ "Yearly" || html =~ "Jährlich" assert html =~ "Yearly" || html =~ "Jährlich"
end end
test "member count column shows correct count", %{conn: conn} do test "member count column shows correct count", %{conn: conn, current_user: admin_user} do
fee_type = create_fee_type(%{interval: :yearly}) fee_type = create_fee_type(%{interval: :yearly})
# Create 3 members with this fee type # Create 3 members with this fee type
Enum.each(1..3, fn _ -> Enum.each(1..3, fn _ ->
create_member(%{membership_fee_type_id: fee_type.id}) create_member(%{membership_fee_type_id: fee_type.id}, admin_user)
end) end)
{:ok, _view, html} = live(conn, "/membership_fee_types") {:ok, _view, html} = live(conn, "/membership_fee_types")
@ -111,9 +103,9 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
end end
describe "delete functionality" do describe "delete functionality" do
test "delete button disabled if type is in use", %{conn: conn} do test "delete button disabled if type is in use", %{conn: conn, current_user: admin_user} do
fee_type = create_fee_type(%{interval: :yearly}) fee_type = create_fee_type(%{interval: :yearly})
create_member(%{membership_fee_type_id: fee_type.id}) create_member(%{membership_fee_type_id: fee_type.id}, admin_user)
{:ok, _view, html} = live(conn, "/membership_fee_types") {:ok, _view, html} = live(conn, "/membership_fee_types")

View file

@ -33,7 +33,7 @@ defmodule MvWeb.ProfileNavigationTest do
end end
end end
describe "navbar" do describe "sidebar" do
test "renders profile button with correct attributes", %{conn: conn} do test "renders profile button with correct attributes", %{conn: conn} do
# Setup: Create and login a user # Setup: Create and login a user
user = create_test_user(%{email: "test@example.com"}) user = create_test_user(%{email: "test@example.com"})
@ -167,8 +167,8 @@ defmodule MvWeb.ProfileNavigationTest do
test "layout shows user data on user profile page", %{conn: conn, user: user} do test "layout shows user data on user profile page", %{conn: conn, user: user} do
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}") {:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
# The navbar (which requires current_user) should be visible # The sidebar (which requires current_user) should be visible
assert html =~ "navbar" assert html =~ "sidebar"
# Profile button should be visible # Profile button should be visible
assert html =~ "Profil" assert html =~ "Profil"
# User ID should be in profile link # User ID should be in profile link

View file

@ -11,20 +11,6 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
require Ash.Query require Ash.Query
setup %{conn: conn} do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
authenticated_conn = conn_with_password_user(conn, user)
%{conn: authenticated_conn, user: user}
end
# Helper to create a membership fee type # Helper to create a membership fee type
defp create_fee_type(attrs) do defp create_fee_type(attrs) do
default_attrs = %{ default_attrs = %{
@ -164,4 +150,153 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
assert html =~ fee_type.name || html =~ "selected" assert html =~ fee_type.name || html =~ "selected"
end end
end end
describe "custom field value preservation" do
test "custom field values preserved when membership fee type changes", %{
conn: conn,
current_user: admin_user
} do
# Create custom field
custom_field =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test Field",
value_type: :string,
required: false
})
|> Ash.create!()
# Create two fee types with same interval
fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly})
fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly})
# Create member with fee type 1 and custom field value
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type1.id
})
|> Ash.create!(actor: admin_user)
# Add custom field value
Mv.Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "Test Value"}
})
|> Ash.create!(actor: admin_user)
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
# Change membership fee type dropdown
html =
view
|> form("#member-form", %{"member[membership_fee_type_id]" => fee_type2.id})
|> render_change()
# Verify custom field value is still present (check for field name or value)
assert html =~ custom_field.name || html =~ "Test Value"
end
test "union/typed values roundtrip correctly", %{conn: conn, current_user: admin_user} do
# Create date custom field
custom_field =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Date Field",
value_type: :date,
required: false
})
|> Ash.create!()
fee_type = create_fee_type(%{interval: :yearly})
# Create member with date custom field value
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
|> Ash.create!(actor: admin_user)
test_date = ~D[2024-01-15]
# Add date custom field value
Mv.Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "date", "_union_value" => test_date}
})
|> Ash.create!(actor: admin_user)
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
# Trigger validation (simulates dropdown change)
html =
view
|> form("#member-form", %{"member[membership_fee_type_id]" => fee_type.id})
|> render_change()
# Verify date value is still present (check for date input or formatted date)
assert html =~ "2024" || html =~ "date"
end
test "removing custom field values works correctly", %{conn: conn, current_user: admin_user} do
# Create custom field
custom_field =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test Field",
value_type: :string,
required: false
})
|> Ash.create!()
fee_type = create_fee_type(%{interval: :yearly})
# Create member with custom field value
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
|> Ash.create!(actor: admin_user)
# Add custom field value
_cfv =
Mv.Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "Test Value"}
})
|> Ash.create!(actor: admin_user)
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
# Change membership fee type to trigger validation
# This should preserve the custom field value
html =
view
|> form("#member-form", %{
"member[membership_fee_type_id]" => fee_type.id
})
|> render_change()
# Form should still be valid and custom field value should be preserved
# The custom field value should still be visible in the form
assert html =~ "Test Value" || html =~ custom_field.name
end
end
end end

View file

@ -1,141 +0,0 @@
defmodule MvWeb.Helpers.MemberHelpersTest do
@moduledoc """
Tests for the display_name/1 helper function in MemberHelpers.
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
alias MvWeb.Helpers.MemberHelpers
describe "display_name/1" do
test "returns full name when both first_name and last_name are present" do
member = %Member{
first_name: "John",
last_name: "Doe",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John Doe"
end
test "returns email when both first_name and last_name are nil" do
member = %Member{
first_name: nil,
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "returns first_name only when last_name is nil" do
member = %Member{
first_name: "John",
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John"
end
test "returns last_name only when first_name is nil" do
member = %Member{
first_name: nil,
last_name: "Doe",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "Doe"
end
test "returns email when first_name and last_name are empty strings" do
member = %Member{
first_name: "",
last_name: "",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "returns email when first_name and last_name are whitespace only" do
member = %Member{
first_name: " ",
last_name: " \t ",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "trims whitespace from name parts" do
member = %Member{
first_name: " John ",
last_name: " Doe ",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John Doe"
end
test "handles one empty string and one nil" do
member = %Member{
first_name: "",
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "handles one nil and one empty string" do
member = %Member{
first_name: nil,
last_name: "",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "handles one whitespace and one nil" do
member = %Member{
first_name: " ",
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "john@example.com"
end
test "handles one valid name and one whitespace" do
member = %Member{
first_name: "John",
last_name: " ",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John"
end
test "handles member with only first_name containing whitespace" do
member = %Member{
first_name: " John ",
last_name: nil,
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "John"
end
test "handles member with only last_name containing whitespace" do
member = %Member{
first_name: nil,
last_name: " Doe ",
email: "john@example.com"
}
assert MemberHelpers.display_name(member) == "Doe"
end
end
end

View file

@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
require Ash.Query require Ash.Query
setup %{conn: conn} do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
conn = conn_with_password_user(conn, user)
%{conn: conn, user: user}
end
# Helper to create a membership fee type # Helper to create a membership fee type
defp create_fee_type(attrs) do defp create_fee_type(attrs) do
default_attrs = %{ default_attrs = %{

View file

@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
require Ash.Query require Ash.Query
setup do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
conn = conn_with_password_user(build_conn(), user)
%{conn: conn, user: user}
end
# Helper to create a membership fee type # Helper to create a membership fee type
defp create_fee_type(attrs) do defp create_fee_type(attrs) do
default_attrs = %{ default_attrs = %{

View file

@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
require Ash.Query require Ash.Query
setup %{conn: conn} do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
conn = conn_with_password_user(conn, user)
%{conn: conn, user: user}
end
# Helper to create a membership fee type # Helper to create a membership fee type
defp create_fee_type(attrs) do defp create_fee_type(attrs) do
default_attrs = %{ default_attrs = %{

View file

@ -7,13 +7,13 @@ defmodule MvWeb.MemberLive.ShowTest do
- Custom Fields section visibility (Issue #282 regression test) - Custom Fields section visibility (Issue #282 regression test)
- Custom field values formatting - Custom field values formatting
## Note on async: false ## Note on async
Tests use `async: false` (not `async: true`) to prevent PostgreSQL deadlocks Tests can run with `async: true` because:
when creating members and custom fields concurrently. This is intentional and - Each test explicitly manages its own custom fields (creates/deletes as needed)
documented here to avoid confusion in commit messages. - The SQL Sandbox provides proper isolation between parallel tests
- Custom field cleanup in tests ensures no interference between tests
""" """
# async: false to prevent PostgreSQL deadlocks when creating members and custom fields use MvWeb.ConnCase, async: true
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
require Ash.Query require Ash.Query
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
@ -113,11 +113,22 @@ defmodule MvWeb.MemberLive.ShowTest do
conn: conn, conn: conn,
member: member member: member
} do } do
# Ensure no custom fields exist for this test
# This ensures test isolation even if previous tests created custom fields
existing_custom_fields = Ash.read!(CustomField)
for cf <- existing_custom_fields do
Ash.destroy!(cf)
end
# Verify no custom fields exist
assert Ash.read!(CustomField) == []
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members/#{member}") {:ok, _view, html} = live(conn, ~p"/members/#{member}")
# Custom Fields section should NOT be visible # Custom Fields section should NOT be visible
refute html =~ gettext("Custom Fields") refute html =~ gettext("Additional Data Fields")
end end
end end

View file

@ -99,6 +99,7 @@ defmodule MvWeb.ConnCase do
@doc """ @doc """
Signs in a user via OIDC and returns a connection with the user authenticated. Signs in a user via OIDC and returns a connection with the user authenticated.
By default creates a user with "user@example.com" for consistency. By default creates a user with "user@example.com" for consistency.
The user will have an admin role for authorization.
""" """
def conn_with_oidc_user(conn, user_attrs \\ %{}) do def conn_with_oidc_user(conn, user_attrs \\ %{}) do
# Ensure unique email for OIDC users # Ensure unique email for OIDC users
@ -109,8 +110,22 @@ defmodule MvWeb.ConnCase do
oidc_id: "oidc_#{unique_id}" oidc_id: "oidc_#{unique_id}"
} }
# Create user using Ash.Seed (supports oidc_id)
user = create_test_user(Map.merge(default_attrs, user_attrs)) user = create_test_user(Map.merge(default_attrs, user_attrs))
sign_in_user_via_oidc(conn, user)
# Create admin role and assign it
admin_role = Mv.Fixtures.role_fixture("admin")
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update()
# Load role for authorization
user_with_role = Ash.load!(user, :role, domain: Mv.Accounts)
sign_in_user_via_oidc(conn, user_with_role)
end end
@doc """ @doc """
@ -122,6 +137,15 @@ defmodule MvWeb.ConnCase do
|> AshAuthentication.Plug.Helpers.store_in_session(user) |> AshAuthentication.Plug.Helpers.store_in_session(user)
end end
@doc """
Creates a connection with an authenticated user that has an admin role.
This is useful for tests that need full access to resources.
"""
def conn_with_admin_user(conn) do
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn_with_password_user(conn, admin_user)
end
setup tags do setup tags do
pid = Mv.DataCase.setup_sandbox(tags) pid = Mv.DataCase.setup_sandbox(tags)
@ -130,6 +154,36 @@ defmodule MvWeb.ConnCase do
# to share the test's database connection in async tests # to share the test's database connection in async tests
conn = Plug.Conn.put_private(conn, :ecto_sandbox, pid) conn = Plug.Conn.put_private(conn, :ecto_sandbox, pid)
{:ok, conn: conn} # Handle role tags for future test extensions
# Default to admin to maintain backward compatibility with existing tests
role = Map.get(tags, :role, :admin)
{conn, user} =
case role do
:admin ->
# Create admin user with role for all tests (unless test overrides with its own user)
# This ensures all tests have an authenticated user with proper authorization
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
authenticated_conn = conn_with_password_user(conn, admin_user)
{authenticated_conn, admin_user}
:member ->
# Create member user for role-based testing
member_user = Mv.Fixtures.user_with_role_fixture("member")
authenticated_conn = conn_with_password_user(conn, member_user)
{authenticated_conn, member_user}
:unauthenticated ->
# No authentication for unauthenticated tests
{conn, nil}
_other ->
# Fallback: treat unknown role as admin for safety
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
authenticated_conn = conn_with_password_user(conn, admin_user)
{authenticated_conn, admin_user}
end
{:ok, conn: conn, current_user: user}
end end
end end

View file

@ -93,4 +93,104 @@ defmodule Mv.Fixtures do
{user, member} {user, member}
end end
@doc """
Creates a role with a specific permission set.
## Parameters
- `permission_set_name` - The permission set name (e.g., "admin", "read_only", "normal_user", "own_data")
## Returns
- Role struct
## Examples
iex> role_fixture("admin")
%Mv.Authorization.Role{permission_set_name: "admin", ...}
"""
def role_fixture(permission_set_name) do
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
case Mv.Authorization.create_role(%{
name: role_name,
description: "Test role for #{permission_set_name}",
permission_set_name: permission_set_name
}) do
{:ok, role} -> role
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
end
end
@doc """
Creates a user with a specific permission set (role).
## Parameters
- `permission_set_name` - The permission set name (e.g., "admin", "read_only", "normal_user", "own_data")
- `user_attrs` - Optional user attributes
## Returns
- User struct with role preloaded
## Examples
iex> admin_user = user_with_role_fixture("admin")
iex> admin_user.role.permission_set_name
"admin"
"""
def user_with_role_fixture(permission_set_name \\ "admin", user_attrs \\ %{}) do
# Create role with permission set
role = role_fixture(permission_set_name)
# Create user
{:ok, user} =
user_attrs
|> Enum.into(%{
email: "user#{System.unique_integer([:positive])}@example.com"
})
|> Mv.Accounts.create_user()
# Assign role to user
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|> Ash.update()
# Reload user with role preloaded (critical for authorization!)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts)
user_with_role
end
@doc """
Creates a member with an actor (for use in tests with policies).
## Parameters
- `attrs` - Map or keyword list of attributes to override defaults
- `actor` - The actor (user) to use for authorization
## Returns
- Member struct
## Examples
iex> admin = user_with_role_fixture("admin")
iex> member_fixture_with_actor(%{first_name: "Alice"}, admin)
%Mv.Membership.Member{first_name: "Alice", ...}
"""
def member_fixture_with_actor(attrs \\ %{}, actor) do
attrs
|> Enum.into(%{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com"
})
|> Mv.Membership.create_member(actor: actor)
|> case do
{:ok, member} -> member
{:error, error} -> raise "Failed to create member: #{inspect(error)}"
end
end
end end