Compare commits

..

99 commits

Author SHA1 Message Date
962f17e89e
test: add ci-build-container to pipeline trigger for testing
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-30 18:46:29 +01:00
56fb0c26e9
feat: docker-compose prod setup 2025-10-30 18:32:05 +01:00
cdc91aec57
dropme: remove other drone tasks for faster debugging 2025-10-30 16:54:06 +01:00
0eab45ebfd
wip: feat(ci): Build docker container 2025-10-30 16:54:06 +01:00
c1f9750972 Merge pull request 'Sorting header for members list closes #152 #175' (#166) from feature/152_sorting_default_fields into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #166
Reviewed-by: moritz <moritz@noreply.git.local-it.org>
2025-10-30 16:44:49 +01:00
8104451d35 format and linting: reduced complexity of function
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-30 16:42:19 +01:00
bb362e1636 formating 2025-10-30 16:42:19 +01:00
41e3a52482 test: updated tests 2025-10-30 16:42:19 +01:00
b71df98ba2 fix: catch invalid sorting order 2025-10-30 16:42:19 +01:00
eb42b9fe0a fix: keep search term on refresh and enter 2025-10-30 16:42:19 +01:00
85e1f370f6 fix: keep search term while sorting 2025-10-30 16:42:19 +01:00
9d98ec2494 formatting 2025-10-30 16:42:19 +01:00
017ca8b32c chore: updated translation 2025-10-30 16:42:19 +01:00
3cfae95b1e test: added tests 2025-10-30 16:42:19 +01:00
c3502a326e docs: formatting, docs and accessibility fix 2025-10-30 16:42:19 +01:00
d9e48a37d2 feat: sort header for members list 2025-10-30 16:42:19 +01:00
687d653fb7 Merge pull request 'sync email between user and member closes #167' (#181) from feature/email-sync into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #181
Reviewed-by: carla <carla@noreply.git.local-it.org>
2025-10-30 16:25:34 +01:00
899039b3ee
add docs
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-23 13:13:29 +02:00
37fcc26b22
add seed test 2025-10-23 13:13:29 +02:00
1495ef4592
fix validation behaviour 2025-10-23 13:13:29 +02:00
001fca1d16
refactor: email sync changes 2025-10-23 13:13:28 +02:00
2693f67d33
refactor: email validations 2025-10-23 13:13:28 +02:00
7522724945
refactor: email sync changes 2025-10-23 13:13:28 +02:00
39afaf3999
feat: email uniqueness constraint between user and member 2025-10-23 13:13:27 +02:00
5a0a261cd6
add action changes for email sync 2025-10-23 13:13:27 +02:00
91c5e17994
email sync tests 2025-10-23 13:13:27 +02:00
b94a4a65d3 Merge pull request 'chore(deps): update postgres to v17.6' (#184) from renovate/postgres into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #184
2025-10-20 19:59:00 +02:00
Renovate Bot
7882370f4a chore(deps): update postgres to v17.6
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-20 16:43:16 +00:00
9a276218c5 Merge pull request 'chore(deps): update dependency just to v1.43.0' (#182) from renovate/asdf-tool-versions into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
Reviewed-on: #182
2025-10-20 15:46:15 +02:00
Renovate Bot
d8ab0d80db chore(deps): update dependency just to v1.43.0
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-18 00:14:27 +00:00
7c295daedc
chore: run renovate each day of the first week of the month
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-17 19:40:03 +02:00
eb5fb5bb59 Merge pull request 'chore(deps): update mix dependencies' (#183) from renovate/mix-dependencies into main
Some checks reported errors
continuous-integration/drone/push Build was killed
Reviewed-on: #183
2025-10-17 19:00:07 +02:00
Renovate Bot
210224626d chore(deps): update mix dependencies
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-17 14:09:15 +00:00
e24a731032
chore: update ash 3.7.0 to 3.7.1
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-10-17 16:04:27 +02:00
cbc977514e
chore: update renovate 2025-10-17 16:04:07 +02:00
e6169e4287
update renovate on push
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-10-17 14:55:05 +02:00
46a5323cd4
set schedule to every minute
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-17 14:41:31 +02:00
c116e39b56 Merge pull request 'create logical link between users and members closes #164' (#172) from feature/user-member-relation into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #172
Reviewed-by: rafael <rafael@noreply.git.local-it.org>
2025-10-16 16:29:48 +02:00
045ae1c3c7
add tests for member deletion
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-16 15:28:31 +02:00
7c1aeddad4
add constraints for member-user and member-property 2025-10-16 15:28:31 +02:00
59a8067c09
add some comments 2025-10-16 15:28:30 +02:00
b47b0d36b5
gender neutral translation 2025-10-16 14:22:58 +02:00
3b0c1da1ab
User email validation 2025-10-16 13:54:57 +02:00
cde619543f
translate all error messages 2025-10-16 13:54:07 +02:00
908517641b
add users link to navbar
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-16 12:30:03 +02:00
23d1ca8a32
fix: axe-core critical and major issues 2025-10-16 12:30:02 +02:00
515cd76cee
feat: add translation 2025-10-16 12:30:02 +02:00
d8ec828df0
feat: make member emails unique 2025-10-16 12:30:01 +02:00
98f4768e00
feat: seed member user relations 2025-10-16 12:30:01 +02:00
eeed537062
feat: add member-user link in member view and user view 2025-10-16 12:30:01 +02:00
72a8415cb3
feat: member user relation 2025-10-16 12:30:01 +02:00
5aa9c37742
feat: Add tests for user-member relationship 2025-10-16 12:30:00 +02:00
8dac30a07b
chore: security update ash from 3.5.43 to 3.7.0
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-16 12:28:47 +02:00
ce9878791e Merge pull request 'Link to userdate from profile button closes #170' (#173) from 170-userdata-for-profile-button into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #173
Reviewed-by: carla <carla@noreply.git.local-it.org>
Reviewed-by: rafael <rafael@noreply.git.local-it.org>
2025-10-16 12:23:26 +02:00
8d3b76b954
chore: disable linter breaking for TODOs
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-16 12:13:44 +02:00
96c5b956e5
Fix error when deleting members
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-16 12:07:39 +02:00
Renovate Bot
e0d40014a1
chore(deps): update mix dependencies 2025-10-16 12:07:39 +02:00
b9503da2c3
docs: polish Readme 2025-10-16 12:07:39 +02:00
30616d5a8d
feat: add license closes #150 2025-10-16 12:07:39 +02:00
00b47a0d3e
fix CI badge 2025-10-16 12:07:39 +02:00
d94f6f2646
docs: add CI link 2025-10-16 12:07:38 +02:00
2f74ec8ccb
docs: reduce horizontal lines 2025-10-16 12:07:38 +02:00
9f8b412d23
docs: polish readme 2025-10-16 12:07:38 +02:00
e880fb3c29 Merge pull request 'polish README closes #158' (#178) from feature/#158-polish-README into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #178
Reviewed-by: rafael <rafael@noreply.git.local-it.org>
2025-10-16 12:05:41 +02:00
8b72235ab3 Merge branch 'main' into feature/#158-polish-README
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-16 12:05:05 +02:00
30c3943884
docs: polish Readme
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-16 11:59:21 +02:00
e2c00f263e Merge pull request 'Fix error when deleting members' (#148) from fix-member-deletion into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #148
Reviewed-by: carla <carla@noreply.git.local-it.org>
2025-10-09 16:45:41 +02:00
7d2b719ca2
Fix error when deleting members
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-09 16:42:44 +02:00
95d065345b Merge pull request 'chore(deps): update mix dependencies' (#180) from renovate/mix-dependencies into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #180
2025-10-09 16:28:14 +02:00
Renovate Bot
d1513d79ed chore(deps): update mix dependencies
Some checks failed
continuous-integration/drone/push Build is passing
renovate/artifacts Artifact file update failure
2025-10-09 00:15:05 +00:00
bb0fc0a627
feat: add license closes #150
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-02 17:12:06 +02:00
b132641753
fix CI badge
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-02 15:40:26 +02:00
7bedaff145
docs: add CI link
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-02 15:37:58 +02:00
306734bcee
docs: reduce horizontal lines
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-02 15:26:23 +02:00
fc4dcc3a6c
docs: polish readme
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-02 15:23:46 +02:00
1d334c7da1
fix: add missing user for view and fix test
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-29 16:59:35 +02:00
d10fcc3da1
style: fix linting errors
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-29 16:41:46 +02:00
d40bc0bb82
Merge remote-tracking branch 'origin/main' into 170-userdata-for-profile-button
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-29 16:07:57 +02:00
863821f3ae
test: fix tests and skip tests for initials generation
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-29 16:05:00 +02:00
e3dd333e89
feat: add userdata for profile button #170 2025-09-29 15:34:00 +02:00
80b79d80cd Merge pull request 'Implement full-text search for members closes #11' (#163) from feature/11-fulltext-search into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #163
Reviewed-by: simon <s.thiessen@local-it.org>
2025-09-29 14:26:36 +02:00
6033e33622
test: add tdd tests for #170 2025-09-29 13:07:43 +02:00
2095d9b0da test: update test for search bar component
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-29 09:00:33 +02:00
e68e1604a4 fix: catch empty search string 2025-09-26 09:22:53 +02:00
02b3084789 formatting 2025-09-17 14:37:04 +02:00
53f6b62289 test: updated tests for member and search bar 2025-09-17 14:36:50 +02:00
78588cbad9 feat: adds SearchBar Live Component 2025-09-17 14:36:13 +02:00
dd03000428 chore: adds tsvector to members 2025-09-17 13:34:14 +02:00
52e76b1a99 Merge pull request 'Add seed data for members' (#147) from seed-members into main
All checks were successful
continuous-integration/drone Build is passing
Reviewed-on: #147
Reviewed-by: carla <carla@noreply.git.local-it.org>
2025-09-11 13:29:43 +02:00
5559b29ff6 Merge branch 'main' into seed-members 2025-09-11 13:29:07 +02:00
a3746dfaaa
Explicitly require ash authentication settings
Previously, we'd rely on defaults for configuring user token
authentication. With these changes, we explicitly require
:session_identifier and :require_token_presence_for_authentication to be
configured in the application environment to make sure the system is
configured the way it should be.
2025-09-11 11:49:46 +02:00
7118782a2d
Add seed data for members 2025-08-21 14:11:55 +02:00
Renovate Bot
96085ea420 chore(deps): update mix dependencies 2025-08-21 13:54:14 +02:00
3456f7f6b7 Merge pull request 'customize login screen and mmbers as landing page closes #68 and #137' (#138) from feature/68_log_in_screen into main
Reviewed-on: #138
Reviewed-by: rafael <rafael@noreply.git.local-it.org>
2025-08-21 13:49:41 +02:00
7e2aa49674 docs: added comment 2025-08-21 13:40:16 +02:00
f0b0de0008 feat: set users locale 2025-08-21 13:29:50 +02:00
992addf0ea feat: memberslist as landing page 2025-08-21 13:29:50 +02:00
2a6f1ccf80 feat: add translation 2025-08-21 13:29:50 +02:00
ddf9348eb8 feat: add overrides for sign in page 2025-08-21 13:29:50 +02:00
83 changed files with 6467 additions and 405 deletions

View file

@ -89,7 +89,7 @@
# If you don't want TODO comments to cause `mix credo` to fail, just
# set this value to 0 (zero).
#
{Credo.Check.Design.TagTODO, [exit_status: 2]},
{Credo.Check.Design.TagTODO, [exit_status: 0]},
#
## Readability Checks

View file

@ -1,13 +1,8 @@
kind: pipeline
type: docker
name: check
name: build-and-publish
services:
- name: postgres
image: docker.io/library/postgres:17.5
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
- name: docker
image: docker:dind
privileged: true
@ -16,26 +11,48 @@ services:
path: /var/run
trigger:
branch:
- main
- ci-build-container # TODO: Remove after testing
event:
- push
- tag
steps:
- name: build & publish container?
- name: build-and-publish-container
image: docker:dind
volumes:
- name: dockersock
path: /var/run
environment:
REGISTRY: git.local-it.org/ci-builder
IMAGE_NAME: mitgliederverwaltung
commands:
- sleep 6 # give docker time to start
- docker build --tag git.local-it.org/ci-builder/mitgliederverwaltung:latest .
# Build image once
- docker build --tag $REGISTRY/$IMAGE_NAME:build-$DRONE_BUILD_NUMBER .
# Login to registry
- docker login --username $DRONE_FORGEJO_ACCOUNT_USERNAME --password $DRONE_FORGEJO_ACCOUNT_PASSWORD git.local-it.org
- docker push git.local-it.org/ci-builder/mitgliederverwaltung:latest
# Tag and push based on event type
- |
if [ "$DRONE_BUILD_EVENT" = "tag" ]; then
# For tag events: use tag version (e.g., v1.0.0 -> 1.0.0) and latest
VERSION=$(echo $DRONE_TAG | sed 's/^v//')
echo "Tagging and pushing version $VERSION"
docker tag $REGISTRY/$IMAGE_NAME:build-$DRONE_BUILD_NUMBER $REGISTRY/$IMAGE_NAME:$VERSION
docker tag $REGISTRY/$IMAGE_NAME:build-$DRONE_BUILD_NUMBER $REGISTRY/$IMAGE_NAME:latest
docker push $REGISTRY/$IMAGE_NAME:$VERSION
docker push $REGISTRY/$IMAGE_NAME:latest
else
# For main branch pushes: use commit SHA and latest
echo "Tagging and pushing commit $DRONE_COMMIT_SHA"
docker tag $REGISTRY/$IMAGE_NAME:build-$DRONE_BUILD_NUMBER $REGISTRY/$IMAGE_NAME:$DRONE_COMMIT_SHA
docker tag $REGISTRY/$IMAGE_NAME:build-$DRONE_BUILD_NUMBER $REGISTRY/$IMAGE_NAME:latest
docker push $REGISTRY/$IMAGE_NAME:$DRONE_COMMIT_SHA
docker push $REGISTRY/$IMAGE_NAME:latest
fi
volumes:
- name: cache
host:
path: /tmp/drone_cache
- name: dockersock
temp: {}
@ -56,7 +73,7 @@ environment:
steps:
- name: renovate
image: renovate/renovate:41.72
image: renovate/renovate:41.151
environment:
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
RENOVATE_TOKEN:

View file

@ -1 +1,16 @@
OIDC_CLIENT_SECRET=
# Production Environment Variables for docker-compose.prod.yml
# Copy this file to .env and fill in the actual values
# Required: Phoenix secrets (generate with: mix phx.gen.secret)
SECRET_KEY_BASE=changeme-run-mix-phx.gen.secret
TOKEN_SIGNING_SECRET=changeme-run-mix-phx.gen.secret
# Required: Hostname for URL generation
PHX_HOST=localhost
# Optional: OIDC Configuration
# These have defaults in docker-compose.prod.yml, only override if needed
# OIDC_CLIENT_ID=mv
# OIDC_BASE_URL=http://localhost:8080/auth/v1
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
# OIDC_CLIENT_SECRET=your-rauthy-client-secret

View file

@ -1,3 +1,3 @@
elixir 1.18.3-otp-27
erlang 27.3.4
just 1.42.4
just 1.43.0

View file

@ -14,7 +14,7 @@
ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim"
ARG RUNNER_IMAGE="debian:bullseye-20250317-slim"
FROM ${BUILDER_IMAGE} as builder
FROM ${BUILDER_IMAGE} AS builder
# install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git \
@ -70,9 +70,9 @@ RUN apt-get update -y && \
# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
WORKDIR "/app"
RUN chown nobody /app

View file

@ -23,7 +23,7 @@ ci-dev: lint audit test
gettext:
mix gettext.extract
mix gettext.merge priv/gettext
mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete
lint:
mix format --check-formatted
@ -35,8 +35,8 @@ audit:
mix deps.audit
mix hex.audit
test: install-dependencies start-database
mix test
test *args: install-dependencies start-database
mix test {{args}}
format:
mix format

662
LICENSE Normal file
View file

@ -0,0 +1,662 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) {{ year }} {{ organization }}
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<http://www.gnu.org/licenses/>.

262
README.md
View file

@ -1,18 +1,266 @@
# mitgliederverwaltung
# Mila
## Testing SSO with rauthy
**Mila** — simple, usable, self-hostable membership management for small to mid-sized clubs.
[![Build Status](https://drone.dev.local-it.cloud/api/badges/local-it/mitgliederverwaltung/status.svg)](https://drone.dev.local-it.cloud/local-it/mitgliederverwaltung)
![License](https://img.shields.io/badge/license-AGPL--v3-blue)
## 🚧 Project Status
⚠️ **Early development** — not production-ready. Expect breaking changes.
Contributions and feedback are welcome!
## ✨ Overview
Mila is a free and open-source membership management tool designed for real club needs.
It is **self-hosting friendly**, aims for **accessibility and GDPR compliance**, and focuses on **usability** instead of feature overload.
## 💡 Why Mila?
Most membership tools for clubs are either:
* **Too complex** — overloaded with features small and mid-sized clubs dont need
* **Too expensive** — hidden fees, closed ecosystems, vendor lock-in
* **Too rigid** — no way to adapt fields, processes, or roles to your clubs reality
**Mila** is different:
* **Simple**: Focused on what clubs really need — members, dues, communication.
* **Usable**: Clean, accessible UI, GDPR-compliant, designed with everyday volunteers in mind.
* **Flexible**: Customize what data you collect about members, role-based permissions, and self-service for members.
* **Truly open**: 100% free and open source — no lock-in, transparent code, self-host friendly.
Our philosophy: **software should help people spend less time on administration and more time on their community.**
## 📸 Screenshots
![Screenshot placeholder](docs/images/screenshot.png)
*This is how Mila might look in action.*
## 🔑 Features
- ✅ Manage member data with ease
- 🚧 Overview of membership fees & payment status
- ✅ Full-text search
- 🚧 Sorting & filtering
- 🚧 Roles & permissions (e.g. board, treasurer)
- ✅ Custom fields (flexible per club needs)
- ✅ SSO via OIDC (tested with Rauthy)
- 🚧 Self-service & online application
- 🚧 Accessibility, GDPR, usability improvements
- 🚧 Email sending
## 🚀 Quick Start (Development)
### Prerequisites
We recommend using **[asdf](https://asdf-vm.com/)** for managing tool versions.
- Tested with: `asdf 0.16.5`
- Required versions are documented in `.tool-versions` in this repo
<details>
<summary>Install system dependencies (Debian/Ubuntu)</summary>
```bash
# Debian 12
apt-get -y install build-essential autoconf m4 libncurses-dev libwxgtk3.2-dev libwxgtk-webview3.2-dev libgl1-mesa-dev libglu1-mesa-dev libpng-dev libssh-dev unixodbc-dev xsltproc fop libxml2-utils openjdk-17-jdk icu-devtools bison flex pkg-config
# Ubuntu 24
apt-get -y install build-essential autoconf m4 libwxgtk3.2-dev libwxgtk-webview3.2-dev libgl1-mesa-dev libglu1-mesa-dev libpng-dev libssh-dev unixodbc-dev xsltproc fop libxml2-utils libncurses-dev openjdk-11-jdk icu-devtools bison flex libreadline-dev
```
</details>
<details>
<summary>Install asdf</summary>
```bash
mkdir ~/.asdf
cd ~/.asdf
wget https://github.com/asdf-vm/asdf/releases/download/v0.16.5/asdf-v0.16.5-linux-amd64.tar.gz
tar -xvf asdf-v0.16.5-linux-amd64.tar.gz
ln -s ~/.asdf/asdf ~/.local/bin/asdf
```
Then follow the official “Shell Configuration” steps in the asdf docs.
*Fish example* (`~/.config/fish/config.fish`):
```fish
asdf completion fish > ~/.config/fish/completions/asdf.fish
set -gx PATH "$HOME/.asdf/shims" $PATH
```
*Bash example* (`~/.bash_profile` and `~/.bashrc`):
```bash
export PATH="${ASDF_DATA_DIR:-$HOME/.asdf}/shims:$PATH"
. <(asdf completion bash)
```
</details>
### Install project dependencies
```bash
git clone https://git.local-it.org/local-it/mitgliederverwaltung.git mila
cd mila
asdf install
# Inside the repo folder:
mix local.hex
mix archive.install hex phx_new
```
> Note: running `mix local.hex` must be done inside the repo folder,
> because `.tool-versions` defines the Erlang/Elixir versions.
### Run the app
1. Copy env file:
```bash
cp .env.example .env
# Set OIDC_CLIENT_SECRET inside .env
```
2. Start everything (database, Mailcrab, Rauthy, app):
```bash
just run
```
3. Services will be available at:
- App: <http://localhost:4000>
- Mail UI: <http://localhost:1080>
- Postgres: `localhost:5000`
## 🔐 Testing SSO locally
Mila uses OIDC for Single Sign-On. In development, a local **Rauthy** instance is provided.
1. `just run`
1. go to [localhost:8080](http://localhost:8080), go to the Admin area
1. Login with "admin@localhost" and password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in docker-compose.yml
1. add client from the admin panel
2. go to [localhost:8080](http://localhost:8080), go to the Admin area
3. Login with "admin@localhost" and password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in docker-compose.yml
4. add client from the admin panel
- Client ID: mv
- redirect uris: http://localhost:4000/auth/user/rauthy/callback
- Authorization Flows: authorization_code
- allowed origins: http://localhost:4000
- access/id token algortihm: RS256 (EDDSA did not work for me, found just few infos in the ashauthentication docs)
1. copy client secret to `.env` file
1. abort and run `just run` again
5. copy client secret to `.env` file
6. abort and run `just run` again
Now you can log in to Mila via OIDC!
## ⚙️ Configuration
- **Env vars:** see `.env.example`
- `OIDC_CLIENT_SECRET` — secret for your OIDC client
- Database defaults (Docker Compose):
- Host: `localhost`
- Port: `5000`
- User/pass: `postgres` / `postgres`
- DB: `mila_dev`
## 🏗️ Architecture
- **Backend:** Elixir, Phoenix, LiveView, Ash Framework
- **Frontend:** Phoenix LiveView + DaisyUI + Heroicons
- **Database:** PostgreSQL (via AshPostgres)
- **Auth:** AshAuthentication (OIDC + password strategy)
- **Mail:** Swoosh
- **i18n:** Gettext
Code structure:
- `lib/mv/` — core Ash resources/domains (`Accounts`, `Membership`)
- `lib/mv_web/` — Phoenix controllers, LiveViews, components
- `assets/` — frontend assets (Tailwind, JS, etc.)
## 🧑‍💻 Development
Useful `just` commands:
- `just run` — start DB, Mailcrab, Rauthy, app
- `just test` — run tests
- `just lint` — run code style checks (credo, formatter)
- `just audit` — run security audits
- `just reset-database` — reset local DB
- `just regen-migrations <name>` — regenerate migrations
## 📦 Production Deployment
### Local Production Testing
For testing the production Docker build locally:
1. **Generate secrets:**
```bash
mix phx.gen.secret # for SECRET_KEY_BASE
mix phx.gen.secret # for TOKEN_SIGNING_SECRET
```
2. **Create `.env` file:**
```bash
# Copy template and edit
cp .env.example .env
nano .env
# Required variables:
SECRET_KEY_BASE=<your-generated-secret>
TOKEN_SIGNING_SECRET=<your-generated-secret>
PHX_HOST=localhost
# Optional (have defaults in docker-compose.prod.yml):
# OIDC_CLIENT_ID=mv
# OIDC_BASE_URL=http://localhost:8080/auth/v1
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
# OIDC_CLIENT_SECRET=<from-rauthy-client>
```
3. **Start development environment** (for Rauthy):
```bash
docker compose up -d
```
4. **Build and start production environment:**
```bash
docker compose -f docker-compose.prod.yml up --build
```
5. **Run database migrations:**
```bash
docker compose -f docker-compose.prod.yml exec app /app/bin/mv eval "Mv.Release.migrate"
```
6. **Access the production app:**
- Production App: http://localhost:4001
- Uses same Rauthy instance as dev (localhost:8080)
**Note:** The local production setup uses `network_mode: host` to share localhost with the development Rauthy instance. For real production deployment, configure an external OIDC provider and remove `network_mode: host`.
### Real Production Deployment
For actual production deployment:
1. **Use an external OIDC provider** (not the local Rauthy)
2. **Update `docker-compose.prod.yml`:**
- Remove `network_mode: host`
- Set `OIDC_BASE_URL` to your production OIDC provider
- Configure proper Docker networks
3. **Set up SSL/TLS** (e.g., via reverse proxy like Nginx/Traefik)
4. **Use secure secrets management** (environment variables, Docker secrets, vault)
5. **Configure database backups**
## 🤝 Contributing
We welcome contributions!
- Open issues and PRs in this repo.
- Please follow existing code style and conventions.
- Expect breaking changes while the project is in early development.
## 📄 License
**License: AGPLv3**
See the [LICENSE](LICENSE) file for details.
## 📬 Contact
- Issues: [GitLab Issues](https://git.local-it.org/local-it/mitgliederverwaltung/-/issues)
- Community links: coming soon.

View file

@ -1,7 +1,7 @@
/* See the Tailwind configuration guide for advanced usage
https://tailwindcss.com/docs/configuration */
@import "tailwindcss" source(none);
@import "tailwindcss";
@source "../css";
@source "../js";
@source "../../lib/mv_web";

View file

@ -16,5 +16,16 @@ config :swoosh, local: false
# Do not print debug messages in production
config :logger, level: :info
# AshAuthentication production configuration
# These must be set at compile-time (not in runtime.exs) because
# Application.compile_env!/3 is used in lib/accounts/user.ex
config :mv, :session_identifier, :jti
config :mv, :require_token_presence_for_authentication, true
# Token signing secret - using a placeholder that MUST be overridden
# at runtime via environment variable in config/runtime.exs
config :mv, :token_signing_secret, "REPLACE_ME_AT_RUNTIME"
# Runtime production configuration, including reading
# of environment variables, is done on config/runtime.exs.

View file

@ -53,12 +53,23 @@ if config_env() == :prod do
config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :mv, :rauthy, redirect_uri: "http://localhost:4000/auth/user/rauthy/callback"
# Rauthy OIDC configuration
config :mv, :rauthy,
client_id: System.get_env("OIDC_CLIENT_ID") || "mv",
base_url: System.get_env("OIDC_BASE_URL") || "http://localhost:8080/auth/v1",
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
redirect_uri: System.get_env("OIDC_REDIRECT_URI") || "http://#{host}:#{port}/auth/user/rauthy/callback"
# AshAuthentication production configuration
config :mv, :session_identifier, :jti
# Token signing secret from environment variable
# This overrides the placeholder value set in prod.exs
token_signing_secret =
System.get_env("TOKEN_SIGNING_SECRET") ||
raise """
environment variable TOKEN_SIGNING_SECRET is missing.
You can generate one by calling: mix phx.gen.secret
"""
config :mv, :require_token_presence_for_authentication, true
config :mv, :token_signing_secret, token_signing_secret
config :mv, MvWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
@ -70,7 +81,13 @@ if config_env() == :prod do
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: port
],
secret_key_base: secret_key_base
secret_key_base: secret_key_base,
# Allow connections from localhost and 127.0.0.1
check_origin: [
"//#{host}",
"//localhost:#{port}",
"//127.0.0.1:#{port}"
]
# ## SSL Support
#

39
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,39 @@
services:
app:
build: .
image: mv:latest
container_name: mv-prod-app
# Use host network for local testing to access localhost:8080 (Rauthy)
# In real production, remove this and use external OIDC provider
network_mode: host
environment:
DATABASE_URL: "ecto://postgres:postgres@localhost:5001/mv_prod"
SECRET_KEY_BASE: "${SECRET_KEY_BASE}"
TOKEN_SIGNING_SECRET: "${TOKEN_SIGNING_SECRET}"
PHX_HOST: "${PHX_HOST}"
PORT: "4001"
PHX_SERVER: "true"
# Rauthy OIDC config - uses localhost because of host network mode
OIDC_CLIENT_ID: "mv"
OIDC_BASE_URL: "http://localhost:8080/auth/v1"
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}"
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback"
depends_on:
- db-prod
restart: unless-stopped
db-prod:
image: postgres:16-alpine
container_name: mv-prod-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: mv_prod
volumes:
- postgres_data_prod:/var/lib/postgresql/data
ports:
- "5001:5432"
restart: unless-stopped
volumes:
postgres_data_prod:

View file

@ -1,11 +1,10 @@
networks:
local:
rauthy-dev:
driver: bridge
services:
db:
image: postgres:17.5-alpine
image: postgres:17.6-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
@ -39,12 +38,8 @@ services:
- LISTEN_SCHEME=http
- PUB_URL=localhost:8080
- BOOTSTRAP_ADMIN_PASSWORD_PLAIN=RauthyTest12345
#- HIQLITE=false
#- PG_HOST=db
#- PG_PORT=5432
#- PG_USER=postgres
#- PG_PASSWORD=postgres
#- PG_DB_NAME=mv_dev
# Disable strict IP validation to allow access from multiple Docker networks
- SESSION_VALIDATE_IP=false
ports:
- "8080:8080"
depends_on:

49
docs/email-sync.md Normal file
View file

@ -0,0 +1,49 @@
## Core Rules
1. **User.email is source of truth** - Always overrides member email when linking
2. **DB constraints** - Prevent duplicates within same table (users.email, members.email)
3. **Custom validations** - Prevent cross-table conflicts only for linked entities
4. **Sync is bidirectional**: User ↔ Member (but User always wins on link)
---
## Decision Tree
```
Action: Create/Update/Link Entity with Email X
├─ Does Email X violate DB constraint (same table)?
│ └─ YES → ❌ FAIL (two users or two members with same email)
├─ Is Entity currently linked? (or being linked?)
│ │
│ ├─ NO (unlinked entity)
│ │ └─ ✅ SUCCESS (no custom validation)
│ │
│ └─ YES (linked or linking)
│ │
│ ├─ Action: Update Linked User Email
│ │ ├─ Email used by other member? → ❌ FAIL (validation)
│ │ └─ Email unique? → ✅ SUCCESS + sync to member
│ │
│ ├─ Action: Update Linked Member Email
│ │ ├─ Email used by other user? → ❌ FAIL (validation)
│ │ └─ Email unique? → ✅ SUCCESS + sync to user
│ │
│ ├─ Action: Link User to Member (both directions)
│ │ ├─ User email used by other member? → ❌ FAIL (validation)
│ │ └─ Otherwise → ✅ SUCCESS + override member email
```
## Sync Triggers
| Action | Sync Direction | When |
|--------|---------------|------|
| Update linked user email | User → Member | Email changed |
| Update linked member email | Member → User | Email changed |
| Link user to member | User → Member | Always (override) |
| Link member to user | User → Member | Always (override) |
| Unlink | None | Emails stay as-is |

View file

@ -12,6 +12,12 @@ defmodule Mv.Accounts.User do
postgres do
table "users"
repo Mv.Repo
references do
# When a member is deleted, set the user's member_id to NULL
# This allows users to continue existing even if their linked member is removed
reference :member, on_delete: :nilify
end
end
@doc """
@ -19,16 +25,15 @@ defmodule Mv.Accounts.User do
Currently password and SSO with Rauthy as OIDC provider
"""
authentication do
session_identifier Application.compile_env(:mv, :session_identifier, :jti)
session_identifier Application.compile_env!(:mv, :session_identifier)
tokens do
enabled? true
token_resource Mv.Accounts.Token
require_token_presence_for_authentication? Application.compile_env(
require_token_presence_for_authentication? Application.compile_env!(
:mv,
:require_token_presence_for_authentication,
false
:require_token_presence_for_authentication
)
store_all_tokens? true
@ -61,15 +66,95 @@ defmodule Mv.Accounts.User do
end
actions do
defaults [:read, :create, :destroy, :update]
# Default actions for framework/tooling integration:
# - :read -> Standard read used across the app and by admin tooling.
# - :destroy-> Standard delete used by admin tooling and maintenance tasks.
#
# NOTE: :create is INTENTIONALLY excluded from defaults!
# Using a default :create would bypass email-synchronization logic.
# Always use one of these explicit create actions instead:
# - :create_user (for manual user creation with optional member link)
# - :register_with_password (for password-based registration)
# - :register_with_rauthy (for OIDC-based registration)
defaults [:read, :destroy]
# Primary generic update action:
# - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix
# helpers that assume a default update action.
# - Intended for simple attribute updates (e.g., :email) and scenarios
# that do NOT need to manage the :member relationship.
# - For linking/unlinking a member (and the related validations), prefer
# the specialized :update_user action below.
update :update do
primary? true
# Required because custom validation functions (email validation, member relationship validation)
# cannot be executed atomically. These validations need to query the database and perform
# complex checks that are not supported in atomic operations.
require_atomic? false
# Sync email changes to linked member (User → Member)
# Only runs when email is being changed
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:email)]
end
end
create :create_user do
description "Creates a new user with optional member relationship. The member relationship is managed through the :member argument."
# Only accept email directly - member_id is NOT in accept list
# This prevents direct foreign key manipulation, forcing use of manage_relationship
accept [:email]
# Allow member to be passed as argument for relationship management
argument :member, :map, allow_nil?: true
upsert? true
# Manage the member relationship during user creation
change manage_relationship(:member, :member,
# Look up existing member and relate to it
on_lookup: :relate,
# Error if member doesn't exist in database
on_no_match: :error,
# If member already linked to this user, ignore (shouldn't happen in create)
on_match: :ignore,
# If no member provided, that's fine (optional relationship)
on_missing: :ignore
)
# Sync user email to member when linking (User → Member)
change Mv.EmailSync.Changes.SyncUserEmailToMember
end
update :update_user do
description "Updates a user and manages the optional member relationship. To change an existing member link, first remove it (set member to nil), then add the new one."
# Only accept email directly - member_id is NOT in accept list
# This prevents direct foreign key manipulation, forcing use of manage_relationship
accept [:email]
# Allow member to be passed as argument for relationship management
argument :member, :map, allow_nil?: true
# Required because custom validation functions (email validation, member relationship validation)
# cannot be executed atomically. These validations need to query the database and perform
# complex checks that are not supported in atomic operations.
require_atomic? false
# Manage the member relationship during user update
change manage_relationship(:member, :member,
# Look up existing member and relate to it
on_lookup: :relate,
# Error if member doesn't exist in database
on_no_match: :error,
# If same member provided, that's fine (allows updates with same member)
on_match: :ignore,
# If no member provided, remove existing relationship (allows member removal)
on_missing: :unrelate
)
# Sync email changes and handle linking (User → Member)
# Runs when email OR member relationship changes
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where any([changing(:email), changing(:member)])
end
end
# Admin action for direct password changes in admin panel
@ -77,6 +162,7 @@ defmodule Mv.Accounts.User do
update :admin_set_password do
accept [:email]
argument :password, :string, allow_nil?: false, sensitive?: true
require_atomic? false
# Set the strategy context that HashPasswordChange expects
change set_context(%{strategy_name: :password})
@ -117,14 +203,67 @@ defmodule Mv.Accounts.User do
|> Ash.Changeset.change_attribute(:email, user_info["preferred_username"])
|> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"])
end
# Sync user email to member when linking (User → Member)
change Mv.EmailSync.Changes.SyncUserEmailToMember
end
end
# Global validations - applied to all relevant actions
validations do
# Password strength policy: minimum 8 characters for all password-related actions
validate string_length(:password, min: 8) do
where action_is([:register_with_password, :admin_set_password])
validate string_length(:password, min: 8),
where: [action_is([:register_with_password, :admin_set_password])],
message: "must have length of at least 8"
# Email uniqueness check for all actions that change the email attribute
# Validates that user email is not already used by another (unlinked) member
validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember
# Email validation with EctoCommons.EmailValidator (same as Member)
# This ensures consistency between User and Member email validation
validate fn changeset, _ ->
# Get email from attribute (Ash.CiString) and convert to string
email = Ash.Changeset.get_attribute(changeset, :email)
email_string = if email, do: to_string(email), else: nil
# Only validate if email is present
if email_string do
changeset2 =
{%{}, %{email: :string}}
|> Ecto.Changeset.cast(%{email: email_string}, [:email])
|> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow])
if changeset2.valid? do
:ok
else
{:error, field: :email, message: "is not a valid email"}
end
else
:ok
end
end
# Prevent overwriting existing member relationship
# This validation ensures race condition safety by requiring explicit two-step process:
# 1. Remove existing member (set member to nil)
# 2. Add new member
# This prevents accidental overwrites when multiple admins work simultaneously
validate fn changeset, _context ->
member_arg = Ash.Changeset.get_argument(changeset, :member)
current_member_id = changeset.data.member_id
# Only trigger if:
# - member argument is provided AND has an ID
# - user currently has a member
# - the new member ID is different from current member ID
if member_arg && member_arg[:id] && current_member_id &&
member_arg[:id] != current_member_id do
{:error,
field: :member, message: "User already has a member. Remove existing member first."}
else
:ok
end
end
end
@ -141,18 +280,35 @@ defmodule Mv.Accounts.User do
attributes do
uuid_primary_key :id
attribute :email, :ci_string, allow_nil?: false, public?: true
# IMPORTANT: Email Synchronization
# When user and member are linked, emails are automatically synced bidirectionally.
# User.email is the source of truth - when a link is established, member.email
# is overridden to match user.email. Subsequent changes to either email will
# sync to the other resource.
# See: Mv.EmailSync.Changes.SyncUserEmailToMember
# Mv.EmailSync.Changes.SyncMemberEmailToUser
attribute :email, :ci_string do
allow_nil? false
public? true
# Same constraints as Member email for consistency
constraints min_length: 5, max_length: 254
end
attribute :hashed_password, :string, sensitive?: true, allow_nil?: true
attribute :oidc_id, :string, allow_nil?: true
end
relationships do
# 1:1 relationship - User can optionally belong to one Member
# This automatically creates a `member_id` attribute in the User table
# The relationship is optional (allow_nil? true by default)
belongs_to :member, Mv.Membership.Member
end
identities do
identity :unique_email, [:email]
identity :unique_oidc_id, [:oidc_id]
identity :unique_member, [:member_id]
end
# You can customize this if you wish, but this is a safe default that

View file

@ -13,7 +13,11 @@ defmodule Mv.Membership.Member do
create :create_member do
primary? true
# Properties can be created along with member
argument :properties, {:array, :map}
# Allow user to be passed as argument for relationship management
# user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true
accept [
:first_name,
@ -32,12 +36,35 @@ defmodule Mv.Membership.Member do
]
change manage_relationship(:properties, type: :create)
# Manage the user relationship during member creation
change manage_relationship(:user, :user,
# Look up existing user and relate to it
on_lookup: :relate,
# Error if user doesn't exist in database
on_no_match: :error,
# Error if user is already linked to another member (prevents "stealing")
on_match: :error,
# If no user provided, that's fine (optional relationship)
on_missing: :ignore
)
# Sync user email to member when linking (User → Member)
# Only runs when user relationship is being changed
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:user)]
end
end
update :update_member do
primary? true
# Required because custom validation function cannot be done atomically
require_atomic? false
# Properties can be updated or created along with member
argument :properties, {:array, :map}
# Allow user to be passed as argument for relationship management
# user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true
accept [
:first_name,
@ -56,6 +83,30 @@ defmodule Mv.Membership.Member do
]
change manage_relationship(:properties, on_match: :update, on_no_match: :create)
# Manage the user relationship during member update
change manage_relationship(:user, :user,
# Look up existing user and relate to it
on_lookup: :relate,
# Error if user doesn't exist in database
on_no_match: :error,
# Error if user is already linked to another member (prevents "stealing")
on_match: :error,
# If no user provided, remove existing relationship (allows user removal)
on_missing: :unrelate
)
# Sync member email to user when email changes (Member → User)
# Only runs when email is being changed
change Mv.EmailSync.Changes.SyncMemberEmailToUser do
where [changing(:email)]
end
# Sync user email to member when linking (User → Member)
# Only runs when user relationship is being changed
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:user)]
end
end
end
@ -67,6 +118,44 @@ defmodule Mv.Membership.Member do
validate present(:last_name)
validate present(:email)
# Email uniqueness check for all actions that change the email attribute
# Validates that member email is not already used by another (unlinked) user
validate Mv.Membership.Member.Validations.EmailNotUsedByOtherUser
# Prevent linking to a user that already has a member
# This validation prevents "stealing" users from other members by checking
# if the target user is already linked to a different member
# This is necessary because manage_relationship's on_match: :error only checks
# if the user is already linked to THIS specific member, not ANY member
validate fn changeset, _context ->
user_arg = Ash.Changeset.get_argument(changeset, :user)
if user_arg && user_arg[:id] do
user_id = user_arg[:id]
current_member_id = changeset.data.id
# Check the current state of the user in the database
case Ash.get(Mv.Accounts.User, user_id) do
# User is free to be linked
{:ok, %{member_id: nil}} ->
:ok
# User already linked to this member (update scenario)
{:ok, %{member_id: ^current_member_id}} ->
:ok
{:ok, %{member_id: _other_member_id}} ->
# User is linked to a different member - prevent "stealing"
{:error, field: :user, message: "User is already linked to another member"}
{:error, _} ->
{:error, field: :user, message: "User not found"}
end
else
:ok
end
end
# Birth date not in the future
validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0),
where: [present(:birth_date)],
@ -122,6 +211,13 @@ defmodule Mv.Membership.Member do
constraints min_length: 1
end
# IMPORTANT: Email Synchronization
# When member and user are linked, emails are automatically synced bidirectionally.
# User.email is the source of truth - when a link is established, member.email
# is overridden to match user.email. Subsequent changes to either email will
# sync to the other resource.
# See: Mv.EmailSync.Changes.SyncUserEmailToMember
# Mv.EmailSync.Changes.SyncMemberEmailToUser
attribute :email, :string do
allow_nil? false
constraints min_length: 5, max_length: 254
@ -166,9 +262,23 @@ defmodule Mv.Membership.Member do
attribute :postal_code, :string do
allow_nil? true
end
attribute :search_vector, AshPostgres.Tsvector,
writable?: false,
public?: false,
select_by_default?: false
end
relationships do
has_many :properties, Mv.Membership.Property
# 1:1 relationship - Member can optionally have one User
# This references the User's member_id attribute
# The relationship is optional (allow_nil? true by default)
has_one :user, Mv.Accounts.User
end
# Define identities for upsert operations
identities do
identity :unique_email, [:email]
end
end

View file

@ -42,4 +42,10 @@ defmodule Mv.Membership.Property do
calculations do
calculate :value_to_string, :string, expr(value[:value] <> "")
end
# Ensure a member can only have one property per property type
# For example: A member can have only one "email" property, one "phone" property, etc.
identities do
identity :unique_property_per_member, [:member_id, :property_type_id]
end
end

View file

@ -0,0 +1,60 @@
defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
@moduledoc """
Validates that the user's email is not already used by another member.
Only validates when:
- User is already linked to a member (member_id != nil) AND email is changing
- User is being linked to a member (member relationship is changing)
This allows creating users with the same email as unlinked members.
"""
use Ash.Resource.Validation
@impl true
def validate(changeset, _opts, _context) do
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
member_changing? = Ash.Changeset.changing_relationship?(changeset, :member)
member_id = Ash.Changeset.get_attribute(changeset, :member_id)
is_linked? = not is_nil(member_id)
# Only validate if:
# 1. User is linked AND email is changing
# 2. User is being linked/unlinked (member relationship changing)
should_validate? = (is_linked? and email_changing?) or member_changing?
if should_validate? do
case Ash.Changeset.fetch_change(changeset, :email) do
{:ok, new_email} ->
check_email_uniqueness(new_email, member_id)
:error ->
# No email change, get current email
current_email = Ash.Changeset.get_attribute(changeset, :email)
check_email_uniqueness(current_email, member_id)
end
else
:ok
end
end
defp check_email_uniqueness(email, exclude_member_id) do
query =
Mv.Membership.Member
|> Ash.Query.filter(email == ^to_string(email))
|> maybe_exclude_id(exclude_member_id)
case Ash.read(query) do
{:ok, []} ->
:ok
{:ok, _} ->
{:error, field: :email, message: "is already used by another member", value: email}
{:error, _} ->
:ok
end
end
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
end

View file

@ -0,0 +1,37 @@
defmodule Mv.EmailSync.Changes.SyncMemberEmailToUser do
@moduledoc """
Synchronizes Member.email User.email
Trigger conditions are configured in resources via `where` clauses:
- Member resource: Use `where: [changing(:email)]`
Used by Member resource for bidirectional email sync.
"""
use Ash.Resource.Change
alias Mv.EmailSync.{Helpers, Loader}
@impl true
def change(changeset, _opts, context) do
# Only recursion protection needed - trigger logic is in `where` clauses
if Map.get(context, :syncing_email, false) do
changeset
else
sync_email(changeset)
end
end
defp sync_email(changeset) do
new_email = Ash.Changeset.get_attribute(changeset, :email)
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
result = callback.(cs)
with {:ok, member} <- Helpers.extract_record(result),
linked_user <- Loader.get_linked_user(member) do
Helpers.sync_email_to_linked_record(result, linked_user, new_email)
else
_ -> result
end
end)
end
end

View file

@ -0,0 +1,62 @@
defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
@moduledoc """
Synchronizes User.email Member.email
User.email is always the source of truth.
Trigger conditions are configured in resources via `where` clauses:
- User resource: Use `where: [changing(:email)]` or `where: any([changing(:email), changing(:member)])`
- Member resource: Use `where: [changing(:user)]`
Can be used by both User and Member resources.
"""
use Ash.Resource.Change
alias Mv.EmailSync.{Helpers, Loader}
@impl true
def change(changeset, _opts, context) do
# Only recursion protection needed - trigger logic is in `where` clauses
if Map.get(context, :syncing_email, false) do
changeset
else
sync_email(changeset)
end
end
defp sync_email(changeset) do
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
result = callback.(cs)
with {:ok, record} <- Helpers.extract_record(result),
{:ok, user, member} <- get_user_and_member(record) do
# 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
case record do
%Mv.Membership.Member{} ->
# Member-side: Override member email in result with user email
Helpers.override_with_linked_email(result, user.email)
%Mv.Accounts.User{} ->
# User-side: Sync user email to linked member in DB
Helpers.sync_email_to_linked_record(result, member, user.email)
end
else
_ -> result
end
end)
end
# Retrieves user and member - works for both resource types
defp get_user_and_member(%Mv.Accounts.User{} = user) do
case Loader.get_linked_member(user) do
nil -> {:error, :no_member}
member -> {:ok, user, member}
end
end
defp get_user_and_member(%Mv.Membership.Member{} = member) do
case Loader.load_linked_user!(member) do
{:ok, user} -> {:ok, user, member}
error -> error
end
end
end

View file

@ -0,0 +1,93 @@
defmodule Mv.EmailSync.Helpers do
@moduledoc """
Shared helper functions for email synchronization between User and Member.
Handles the complexity of `around_transaction` callback results and
provides clean abstractions for email updates within transactions.
"""
require Logger
import Ecto.Changeset
@doc """
Extracts the record from an Ash action result.
Handles both 2-tuple `{:ok, record}` and 4-tuple
`{:ok, record, changeset, notifications}` patterns.
"""
def extract_record({:ok, record, _changeset, _notifications}), do: {:ok, record}
def extract_record({:ok, record}), do: {:ok, record}
def extract_record({:error, _} = error), do: error
@doc """
Updates the result with a new record while preserving the original structure.
If the original result was a 4-tuple, returns a 4-tuple with the updated record.
If it was a 2-tuple, returns a 2-tuple with the updated record.
"""
def update_result_record({:ok, _old_record, changeset, notifications}, new_record) do
{:ok, new_record, changeset, notifications}
end
def update_result_record({:ok, _old_record}, new_record) do
{:ok, new_record}
end
@doc """
Updates an email field directly via Ecto within the current transaction.
This bypasses Ash's action system to ensure the update happens in the
same database transaction as the parent action.
"""
def update_email_via_ecto(record, new_email) do
record
|> cast(%{email: to_string(new_email)}, [:email])
|> Mv.Repo.update()
end
@doc """
Synchronizes email to a linked record if it exists.
Returns the original result unchanged, or an error if sync fails.
"""
def sync_email_to_linked_record(result, linked_record, new_email) do
with {:ok, _source} <- extract_record(result),
record when not is_nil(record) <- linked_record,
{:ok, _updated} <- update_email_via_ecto(record, new_email) do
# Successfully synced - return original result unchanged
result
else
nil ->
# No linked record - return original result
result
{:error, error} ->
# Sync failed - log and propagate error to rollback transaction
Logger.error("Email sync failed: #{inspect(error)}")
{:error, error}
end
end
@doc """
Overrides the record's email with the linked email if emails differ.
Returns updated result with new record, or original result if no update needed.
"""
def override_with_linked_email(result, linked_email) do
with {:ok, record} <- extract_record(result),
true <- record.email != to_string(linked_email),
{:ok, updated_record} <- update_email_via_ecto(record, linked_email) do
# Email was different - return result with updated record
update_result_record(result, updated_record)
else
false ->
# Emails already match - no update needed
result
{:error, error} ->
# Override failed - log and propagate error
Logger.error("Email override failed: #{inspect(error)}")
{:error, error}
end
end
end

View file

@ -0,0 +1,40 @@
defmodule Mv.EmailSync.Loader do
@moduledoc """
Helper functions for loading linked records in email synchronization.
Centralizes the logic for retrieving related User/Member entities.
"""
@doc """
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
case Ash.get(Mv.Membership.Member, id) do
{:ok, member} -> member
{:error, _} -> nil
end
end
@doc """
Loads the user linked to a member, returns nil if not linked or on error.
"""
def get_linked_user(member) do
case Ash.load(member, :user) do
{:ok, %{user: user}} -> user
{:error, _} -> nil
end
end
@doc """
Loads the user linked to a member, returning an error tuple if not linked.
Useful when a link is required for the operation.
"""
def load_linked_user!(member) do
case Ash.load(member, :user) do
{:ok, %{user: user}} when not is_nil(user) -> {:ok, user}
{:ok, _} -> {:error, :no_linked_user}
{:error, _} = error -> error
end
end
end

View file

@ -0,0 +1,58 @@
defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
@moduledoc """
Validates that the member's email is not already used by another user.
Only validates when:
- Member is already linked to a user (user != nil) AND email is changing
- Member is being linked to a user (user relationship is changing)
This allows creating members with the same email as unlinked users.
"""
use Ash.Resource.Validation
@impl true
def validate(changeset, _opts, _context) do
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
linked_user_id = get_linked_user_id(changeset.data)
is_linked? = not is_nil(linked_user_id)
# Only validate if member is already linked AND email is changing
# Do NOT validate when member is being linked (email will be overridden from user)
should_validate? = is_linked? and email_changing?
if should_validate? do
new_email = Ash.Changeset.get_attribute(changeset, :email)
check_email_uniqueness(new_email, linked_user_id)
else
:ok
end
end
defp check_email_uniqueness(email, exclude_user_id) do
query =
Mv.Accounts.User
|> Ash.Query.filter(email == ^email)
|> maybe_exclude_id(exclude_user_id)
case Ash.read(query) do
{:ok, []} ->
:ok
{:ok, _} ->
{:error, field: :email, message: "is already used by another user", value: email}
{:error, _} ->
:ok
end
end
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
defp get_linked_user_id(member_data) do
case Ash.load(member_data, :user) do
{:ok, %{user: %{id: id}}} -> id
_ -> nil
end
end
end

View file

@ -1,5 +1,6 @@
defmodule MvWeb.AuthOverrides do
use AshAuthentication.Phoenix.Overrides
use Gettext, backend: MvWeb.Gettext
# configure your UI overrides here
@ -14,7 +15,22 @@ defmodule MvWeb.AuthOverrides do
# set :text_class, "bg-red-500"
# end
# override AshAuthentication.Phoenix.Components.SignIn do
# set :show_banner, false
# end
# Avoid full-width for the Sign In Form
override AshAuthentication.Phoenix.Components.SignIn do
set :root_class, "md:min-w-md"
end
# Replace banner logo with text
override AshAuthentication.Phoenix.Components.Banner do
set :text, "Mitgliederverwaltung"
set :image_url, nil
end
# Translate the or in the horizontal rule to German
override AshAuthentication.Phoenix.Components.HorizontalRule do
set :text,
Gettext.with_locale(MvWeb.Gettext, "de", fn ->
Gettext.gettext(MvWeb.Gettext, "or")
end)
end
end

View file

@ -14,7 +14,8 @@ defmodule MvWeb.Layouts do
embed_templates "layouts/*"
@doc """
Renders the app layout
Renders the app layout. Can be used with or without a current_user.
When current_user is present, it will show the navigation bar.
## Examples
@ -22,9 +23,15 @@ defmodule MvWeb.Layouts do
<h1>Content</h1>
</Layout.app>
<Layouts.app flash={@flash} current_user={@current_user}>
<h1>Authenticated Content</h1>
</Layout.app>
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_user, :map, default: nil, doc: "the current user, if authenticated"
attr :current_scope, :map,
default: nil,
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
@ -33,7 +40,9 @@ defmodule MvWeb.Layouts do
def app(assigns) do
~H"""
<.navbar />
<%= if @current_user do %>
<.navbar current_user={@current_user} />
<% end %>
<main class="px-4 py-20 sm:px-6 lg:px-16">
<div class="mx-auto max-full space-y-4">
{render_slot(@inner_block)}

View file

@ -4,6 +4,11 @@ defmodule MvWeb.Layouts.Navbar do
"""
use Phoenix.Component
use Gettext, backend: MvWeb.Gettext
use MvWeb, :verified_routes
attr :current_user, :map,
required: true,
doc: "The current user - navbar is only shown when user is present"
def navbar(assigns) do
~H"""
@ -11,20 +16,27 @@ defmodule MvWeb.Layouts.Navbar do
<div class="flex-1">
<a class="btn btn-ghost text-xl">Mitgliederverwaltung</a>
<ul class="menu menu-horizontal bg-base-200">
<li><a href="/members">{gettext("Members")}</a></li>
<li><a>Transaktionen</a></li>
<li><.link navigate="/members">{gettext("Members")}</.link></li>
<li><.link navigate="/users">{gettext("Users")}</.link></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()} />
<select name="locale" onchange="this.form.submit()" class="select select-sm">
<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">
<label class="flex cursor-pointer gap-2" aria-label={gettext("Toggle dark mode")}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
@ -35,11 +47,17 @@ defmodule MvWeb.Layouts.Navbar do
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" />
<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"
@ -50,6 +68,7 @@ defmodule MvWeb.Layouts.Navbar do
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>
@ -65,12 +84,14 @@ defmodule MvWeb.Layouts.Navbar do
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow"
>
<li>
<a>
<.link navigate={~p"/users/#{@current_user.id}"}>
{gettext("Profil")}
</a>
</.link>
</li>
<li><a>{gettext("Settings")}</a></li>
<li><a href="sign-out">{gettext("Logout")}</a></li>
<li>
<.link href={~p"/sign-out"}>{gettext("Logout")}</.link>
</li>
</ul>
</div>
</div>

View file

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang={Gettext.get_locale()}>
<head>
{Application.get_env(:live_debugger, :live_debugger_tags)}

View file

@ -0,0 +1,64 @@
defmodule MvWeb.Components.SearchBarComponent do
@moduledoc """
Provides the SearchBar Live-Component.
- uses the DaisyUI search input field
- sends search_changed event to parent live view with a query
"""
use MvWeb, :live_component
@impl true
def update(%{query: query}, socket) do
socket =
socket
|> assign_new(:query, fn -> query || "" end)
|> assign_new(:placeholder, fn -> gettext("Search...") end)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<form phx-submit="search" phx-target={@myself} class="flex" role="search" aria-label="Search">
<label class="input">
<svg
class="h-[1em] opacity-50"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
aria-hidden="true"
>
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input
type="search"
placeholder={@placeholder}
value={@query}
name="query"
data-testid="search-input"
phx-change="search"
phx-target={@myself}
phx-debounce="300"
/>
</label>
</form>
"""
end
@impl true
# Function to handle the search
def handle_event("search", %{"query" => q}, socket) do
# Forward a high level message to the parent
send(self(), {:search_changed, q})
{:noreply, assign(socket, :query, q)}
end
end

View file

@ -0,0 +1,64 @@
defmodule MvWeb.Components.SortHeaderComponent do
@moduledoc """
Sort Header that can be used as column header and sorts a table:
Props:
- field: atom() # Ash Field for sorting
- label: string() # Column Heading (can be an heex template)
- sort_field: atom() | nil # current sort field from parent liveview
- sort_order: :asc | :desc | nil # current sorting order
"""
use MvWeb, :live_component
@impl true
def update(assigns, socket) do
{:ok, assign(socket, assigns)}
end
# Check if we can add the aria-sort label directly to the daisyUI header
# aria-sort={aria_sort(@field, @sort_field, @sort_order)}
@impl true
def render(assigns) do
~H"""
<div class="tooltip" data-tip={aria_sort(@field, @sort_field, @sort_order)}>
<button
type="button"
aria-label={aria_sort(@field, @sort_field, @sort_order)}
class="btn btn-ghost select-none"
phx-click="sort"
phx-value-field={@field}
phx-target={@myself}
data-testid={@field}
>
{@label}
<%= if @sort_field == @field do %>
<.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
<% else %>
<.icon
name="hero-chevron-up-down"
class="opacity-40"
/>
<% end %>
</button>
</div>
"""
end
@impl true
def handle_event("sort", %{"field" => field_str}, socket) do
send(self(), {:sort, field_str})
{:noreply, socket}
end
# -------------------------------------------------
# Hilfsfunktionen für ARIA Attribute & Icon SVG
# -------------------------------------------------
defp aria_sort(field, sort_field, dir) when field == sort_field do
case dir do
:asc -> gettext("ascending")
:desc -> gettext("descending")
nil -> gettext("Click to sort")
end
end
defp aria_sort(_, _, _), do: gettext("Click to sort")
end

View file

@ -4,7 +4,7 @@ defmodule MvWeb.MemberLive.Form do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@page_title}
<:subtitle>

View file

@ -1,27 +1,34 @@
defmodule MvWeb.MemberLive.Index do
use MvWeb, :live_view
import MvWeb.TableComponents
import Ash.Expr
import Ash.Query
@impl true
def mount(_params, _session, socket) do
members = Ash.read!(Mv.Membership.Member)
sorted = Enum.sort_by(members, & &1.first_name)
socket =
socket
|> assign(:page_title, gettext("Members"))
|> assign(:query, "")
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:selected_members, [])
{:ok,
socket
|> assign(:page_title, gettext("Members"))
|> assign(:sort_field, :first_name)
|> assign(:sort_order, :asc)
|> assign(:members, sorted)
|> assign(:selected_members, [])}
# We call handle params to use the query from the URL
{:ok, socket}
end
# -----------------------------------------------------------------
# Handle Events
# -----------------------------------------------------------------
# Delete a member
@impl true
def handle_event("delete", %{"id" => id}, socket) do
member = Ash.get!(Mv.Membership.Member, id)
Ash.destroy!(member)
{:noreply, stream_delete(socket, :members, member)}
updated_members = Enum.reject(socket.assigns.members, &(&1.id == id))
{:noreply, assign(socket, :members, updated_members)}
end
# Selects one member in the list of members
@ -37,32 +44,7 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, assign(socket, :selected_members, selected)}
end
# Sorts the list of members according to a field, when you click on the column header
@impl true
def handle_event("sort", %{"field" => field_str}, socket) do
members = socket.assigns.members
field = String.to_existing_atom(field_str)
new_order =
if socket.assigns.sort_field == field do
toggle_order(socket.assigns.sort_order)
else
:asc
end
sorted_members =
members
|> Enum.sort_by(&Map.get(&1, field), sort_fun(new_order))
{:noreply,
socket
|> assign(:sort_field, field)
|> assign(:sort_order, new_order)
|> assign(:members, sorted_members)}
end
# Selects all members in the list of members
# Selects all members in the list of members
@impl true
def handle_event("select_all", _params, socket) do
members = socket.assigns.members
@ -79,8 +61,235 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, assign(socket, :selected_members, selected)}
end
# -----------------------------------------------------------------
# Handle Infos from Child Components
# -----------------------------------------------------------------
# Sorts the list of members according to a field, when you click on the column header
@impl true
def handle_info({:sort, field_str}, socket) do
field = String.to_existing_atom(field_str)
old_field = socket.assigns.sort_field
{new_order, new_field} =
if socket.assigns.sort_field == field do
{toggle_order(socket.assigns.sort_order), field}
else
{:asc, field}
end
active_id = :"sort_#{new_field}"
old_id = :"sort_#{old_field}"
# Update the new SortHeader
send_update(MvWeb.Components.SortHeaderComponent,
id: active_id,
sort_field: new_field,
sort_order: new_order
)
# Reset the current SortHeader
send_update(MvWeb.Components.SortHeaderComponent,
id: old_id,
sort_field: new_field,
sort_order: new_order
)
existing_search_query = socket.assigns.query
# Build the URL with queries
query_params = %{
"query" => existing_search_query,
"sort_field" => Atom.to_string(new_field),
"sort_order" => Atom.to_string(new_order)
}
# Set the new path with params
new_path = ~p"/members?#{query_params}"
# Push the new URL
{:noreply,
push_patch(socket,
to: new_path,
replace: true
)}
end
# Function to handle search
@impl true
def handle_info({:search_changed, q}, socket) do
socket = load_members(socket, q)
existing_field_query = socket.assigns.sort_field
existing_sort_query = socket.assigns.sort_order
# Build the URL with queries
query_params = %{
"query" => q,
"sort_field" => existing_field_query,
"sort_order" => existing_sort_query
}
# Set the new path with params
new_path = ~p"/members?#{query_params}"
# Push the new URL
{:noreply,
push_patch(socket,
to: new_path,
replace: true
)}
end
# -----------------------------------------------------------------
# Handle Params from the URL
# -----------------------------------------------------------------
@impl true
def handle_params(params, _url, socket) do
socket =
socket
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> load_members(params["query"])
{:noreply, socket}
end
# -------------------------------------------------------------
# FUNCTIONS
# -------------------------------------------------------------
# Load members eg based on a query for sorting
defp load_members(socket, search_query) do
query =
Mv.Membership.Member
|> Ash.Query.new()
|> Ash.Query.select([
:id,
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
])
# Apply the search filter first
query = apply_search_filter(query, search_query)
# Apply sorting based on current socket state
query = maybe_sort(query, socket.assigns.sort_field, socket.assigns.sort_order)
members = Ash.read!(query)
assign(socket, :members, members)
end
# -------------------------------------------------------------
# Helper Functions
# -------------------------------------------------------------
# Function to apply search query
defp apply_search_filter(query, search_query) do
if search_query && String.trim(search_query) != "" do
query
|> filter(expr(fragment("search_vector @@ plainto_tsquery('simple', ?)", ^search_query)))
else
query
end
end
# Functions to toggle sorting order
defp toggle_order(:asc), do: :desc
defp toggle_order(:desc), do: :asc
defp sort_fun(:asc), do: &<=/2
defp sort_fun(:desc), do: &>=/2
defp toggle_order(nil), do: :asc
# Function to sort the column if needed
defp maybe_sort(query, nil, _), do: query
defp maybe_sort(query, field, :asc) when not is_nil(field),
do: Ash.Query.sort(query, [{field, :asc}])
defp maybe_sort(query, field, :desc) when not is_nil(field),
do: Ash.Query.sort(query, [{field, :desc}])
defp maybe_sort(query, _, _), do: query
# Validate that a field is sortable
defp valid_sort_field?(field) when is_atom(field) do
valid_fields = [
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
]
field in valid_fields
end
defp valid_sort_field?(_), do: false
# Function to maybe update the sort
defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do
field = determine_field(socket.assigns.sort_field, sf)
order = determine_order(socket.assigns.sort_order, so)
socket
|> assign(:sort_field, field)
|> assign(:sort_order, order)
end
defp maybe_update_sort(socket, _), do: socket
defp determine_field(default, sf) do
case sf do
"" ->
default
nil ->
default
sf when is_binary(sf) ->
sf
|> String.to_existing_atom()
|> handle_atom_conversion(default)
sf when is_atom(sf) ->
handle_atom_conversion(sf, default)
_ ->
default
end
end
defp handle_atom_conversion(val, default) when is_atom(val) do
if valid_sort_field?(val), do: val, else: default
end
defp handle_atom_conversion(_, default), do: default
defp determine_order(default, so) do
case so do
"" -> default
nil -> default
so when so in ["asc", "desc"] -> String.to_atom(so)
_ -> default
end
end
# Function to update search parameters
defp maybe_update_search(socket, %{"query" => query}) when query != "" do
assign(socket, :query, query)
end
defp maybe_update_search(socket, _params) do
# Keep the previous search query if no new one is provided
socket
end
end

View file

@ -1,4 +1,4 @@
<Layouts.app flash={@flash}>
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Members")}
<:actions>
@ -8,6 +8,13 @@
</:actions>
</.header>
<.live_component
module={MvWeb.Components.SearchBarComponent}
id="search-bar"
query={@query}
placeholder={gettext("Search...")}
/>
<.table
id="members"
rows={@members}
@ -45,23 +52,139 @@
<:col
:let={member}
label={
sort_button(%{
field: :first_name,
label: gettext("Name"),
sort_field: @sort_field,
sort_order: @sort_order
})
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_first_name}
field={:first_name}
label={gettext("First name")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.first_name} {member.last_name}
</:col>
<:col :let={member} label={gettext("Email")}>{member.email}</:col>
<:col :let={member} label={gettext("Street")}>{member.street}</:col>
<:col :let={member} label={gettext("House Number")}>{member.house_number}</:col>
<:col :let={member} label={gettext("Postal Code")}>{member.postal_code}</:col>
<:col :let={member} label={gettext("City")}>{member.city}</:col>
<:col :let={member} label={gettext("Phone Number")}>{member.phone_number}</:col>
<:col :let={member} label={gettext("Join Date")}>{member.join_date}</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_email}
field={:email}
label={gettext("Email")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.email}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_street}
field={:street}
label={gettext("Street")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.street}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_house_number}
field={:house_number}
label={gettext("House Number")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.house_number}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_postal_code}
field={:postal_code}
label={gettext("Postal Code")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.postal_code}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_city}
field={:city}
label={gettext("City")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.city}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_phone_number}
field={:phone_number}
label={gettext("Phone Number")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.phone_number}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_join_date}
field={:join_date}
label={gettext("Join Date")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.join_date}
</:col>
<:action :let={member}>
<div class="sr-only">

View file

@ -5,14 +5,15 @@ defmodule MvWeb.MemberLive.Show do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@member.first_name} {@member.last_name}
<:subtitle>{gettext("This is a member record from your database.")}</:subtitle>
<:actions>
<.button navigate={~p"/members"}>
<.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
<.icon name="hero-arrow-left" />
<span class="sr-only">{gettext("Back to members list")}</span>
</.button>
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> {gettext("Edit Member")}
@ -37,6 +38,19 @@ defmodule MvWeb.MemberLive.Show do
<:item title={gettext("Street")}>{@member.street}</:item>
<:item title={gettext("House Number")}>{@member.house_number}</:item>
<:item title={gettext("Postal Code")}>{@member.postal_code}</:item>
<:item title={gettext("Linked User")}>
<%= if @member.user do %>
<.link
navigate={~p"/users/#{@member.user}"}
class="text-blue-600 hover:text-blue-800 underline"
>
<.icon name="hero-user" class="h-4 w-4 inline mr-1" />
{@member.user.email}
</.link>
<% else %>
<span class="text-gray-500 italic">{gettext("No user linked")}</span>
<% end %>
</:item>
</.list>
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Properties")}</h3>
@ -67,7 +81,7 @@ defmodule MvWeb.MemberLive.Show do
query =
Mv.Membership.Member
|> filter(id == ^id)
|> load(properties: [:property_type])
|> load([:user, properties: [:property_type]])
member = Ash.read_one!(query)

View file

@ -4,7 +4,7 @@ defmodule MvWeb.PropertyLive.Form do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@page_title}
<:subtitle>{gettext("Use this form to manage property records in your database.")}</:subtitle>

View file

@ -4,7 +4,7 @@ defmodule MvWeb.PropertyLive.Index do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Listing Properties
<:actions>

View file

@ -4,7 +4,7 @@ defmodule MvWeb.PropertyLive.Show do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Property {@property.id}
<:subtitle>This is a property record from your database.</:subtitle>

View file

@ -4,7 +4,7 @@ defmodule MvWeb.PropertyTypeLive.Form do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@page_title}
<:subtitle>

View file

@ -4,7 +4,7 @@ defmodule MvWeb.PropertyTypeLive.Index do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Listing Property types
<:actions>

View file

@ -4,7 +4,7 @@ defmodule MvWeb.PropertyTypeLive.Show do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Property type {@property_type.id}
<:subtitle>This is a property_type record from your database.</:subtitle>

View file

@ -4,7 +4,7 @@ defmodule MvWeb.UserLive.Form do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@page_title}
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>

View file

@ -1,4 +1,4 @@
<Layouts.app flash={@flash}>
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Listing Users")}
<:actions>

View file

@ -4,14 +4,15 @@ defmodule MvWeb.UserLive.Show do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("User")} {@user.email}
<:subtitle>{gettext("This is a user record from your database.")}</:subtitle>
<:actions>
<.button navigate={~p"/users"}>
<.button navigate={~p"/users"} aria-label={gettext("Back to users list")}>
<.icon name="hero-arrow-left" />
<span class="sr-only">{gettext("Back to users list")}</span>
</.button>
<.button variant="primary" navigate={~p"/users/#{@user}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> {gettext("Edit User")}
@ -26,6 +27,19 @@ defmodule MvWeb.UserLive.Show do
<:item title={gettext("Password Authentication")}>
{if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")}
</:item>
<:item title={gettext("Linked Member")}>
<%= if @user.member do %>
<.link
navigate={~p"/members/#{@user.member}"}
class="text-blue-600 hover:text-blue-800 underline"
>
<.icon name="hero-users" class="h-4 w-4 inline mr-1" />
{@user.member.first_name} {@user.member.last_name}
</.link>
<% else %>
<span class="text-gray-500 italic">{gettext("No member linked")}</span>
<% end %>
</:item>
</.list>
</Layouts.app>
"""
@ -33,9 +47,11 @@ defmodule MvWeb.UserLive.Show do
@impl true
def mount(%{"id" => id}, _session, socket) do
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member])
{:ok,
socket
|> assign(:page_title, gettext("Show User"))
|> assign(:user, Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts))}
|> assign(:user, user)}
end
end

View file

@ -1,6 +1,6 @@
defmodule MvWeb.LiveHelpers do
def on_mount(:default, _params, session, socket) do
locale = session["locale"] || "en"
locale = session["locale"] || "de"
Gettext.put_locale(locale)
{:cont, socket}
end

View file

@ -28,15 +28,26 @@ defmodule MvWeb.LiveUserAuth do
end
end
def on_mount(:live_user_required, _params, _session, socket) do
if socket.assigns[:current_user] do
{:cont, socket}
else
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/sign-in")}
def on_mount(:live_user_required, _params, session, socket) do
socket = AshAuthentication.Phoenix.LiveSession.assign_new_resources(socket, session)
case socket.assigns do
%{current_user: %{} = user} ->
{:cont, assign(socket, :current_user, user)}
_ ->
socket = Phoenix.LiveView.redirect(socket, to: ~p"/sign-in")
{:halt, socket}
end
end
def on_mount(:live_no_user, _params, _session, socket) do
def on_mount(:live_no_user, _params, session, socket) do
# Set the locale for not logged in user to set the language in the Log-In Screen
# otherwise the locale is not taken for the Log-In Screen
locale = session["locale"] || "en"
Gettext.put_locale(MvWeb.Gettext, locale)
{:cont, assign(socket, :locale, locale)}
if socket.assigns[:current_user] do
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")}
else

View file

@ -47,7 +47,7 @@ defmodule MvWeb.Router do
"""
ash_authentication_live_session :authentication_required,
on_mount: {MvWeb.LiveUserAuth, :live_user_required} do
get "/", PageController, :home
live "/", MemberLive.Index, :index
live "/members", MemberLive.Index, :index
live "/members/new", MemberLive.Form, :new
@ -85,19 +85,19 @@ defmodule MvWeb.Router do
reset_path: "/reset",
auth_routes_prefix: "/auth",
on_mount: [{MvWeb.LiveUserAuth, :live_no_user}],
overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default],
gettext_backend: {MvWeb.Gettext, "default"}
overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.DaisyUI],
gettext_backend: {MvWeb.Gettext, "auth"}
# Remove this if you do not want to use the reset password feature
reset_route auth_routes_prefix: "/auth",
overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default],
gettext_backend: {MvWeb.Gettext, "default"}
overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.DaisyUI],
gettext_backend: {MvWeb.Gettext, "auth"}
# Remove this if you do not use the confirmation strategy
confirm_route Mv.Accounts.User, :confirm_new_user,
auth_routes_prefix: "/auth",
overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default],
gettext_backend: {MvWeb.Gettext, "default"}
overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.DaisyUI],
gettext_backend: {MvWeb.Gettext, "auth"}
# Remove this if you do not use the magic link strategy.
# magic_sign_in_route(Mv.Accounts.User, :magic_link,
@ -139,8 +139,47 @@ defmodule MvWeb.Router do
end
defp set_locale(conn, _opts) do
locale = get_session(conn, :locale) || "en"
locale =
get_session(conn, :locale) ||
extract_locale_from_headers(conn.req_headers)
Gettext.put_locale(MvWeb.Gettext, locale)
conn
|> put_session(:locale, locale)
|> assign(:locale, locale)
end
# Get locale from user
defp extract_locale_from_headers(headers) do
headers
|> Enum.find_value(fn
{"accept-language", value} -> value
_ -> nil
end)
|> parse_accept_language()
|> Enum.find(&supported_locale?/1)
|> fallback_locale()
end
defp parse_accept_language(nil), do: []
defp parse_accept_language(header) do
header
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.map(fn lang ->
lang
# we only want the first part
|> String.split(";")
|> hd()
|> String.split("-")
|> hd()
end)
end
# Our supported languages for now are german and english, english as fallback language
defp supported_locale?(locale), do: locale in ["en", "de"]
defp fallback_locale(nil), do: "en"
defp fallback_locale(locale), do: locale
end

View file

@ -35,9 +35,9 @@ defmodule Mv.MixProject do
# Type `mix help deps` for examples and options.
defp deps do
[
{:tidewave, "~> 0.2", only: [:dev]},
{:tidewave, "~> 0.5", only: [:dev]},
{:sourceror, "~> 1.8", only: [:dev, :test]},
{:live_debugger, "~> 0.3", only: [:dev]},
{:live_debugger, "~> 0.4", only: [:dev]},
{:ash_admin, "~> 0.13"},
{:ash_postgres, "~> 2.0"},
{:ash_phoenix, "~> 2.0"},
@ -56,7 +56,7 @@ defmodule Mv.MixProject do
{:lazy_html, ">= 0.0.0", only: :test},
{:phoenix_live_dashboard, "~> 0.8.3"},
{:esbuild, "~> 0.9", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.4", runtime: Mix.env() == :dev},
{:heroicons,
github: "tailwindlabs/heroicons",
tag: "v2.2.0",

View file

@ -1,24 +1,25 @@
%{
"ash": {:hex, :ash, "3.5.33", "2d4986050ce1c86f711b53f9bb40d6b227871f0cc771dab0b8b814a75a27c5ab", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.4 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.2.65 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c7d1e043059967df749f9445bb903d62ed9c1defb5d45f6ddf32754b411ae93"},
"ash_admin": {:hex, :ash_admin, "0.13.13", "d6f491587659c63c1e37b542bdef69c1e2dce9e13696e1fa537488983b98ac10", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "3378f54b5bfdbecc735ed848f137223692be4320975d01c23bd64e47db1f1a9a"},
"ash_authentication": {:hex, :ash_authentication, "4.9.9", "23ec61bedc3157c258ece622c6f0f6a7645df275ff5e794d513cc6e8798471eb", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, ">= 3.4.29 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "~> 0.2.13", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "ab8bd1277ff570425346dcf22dd14a059d9bbce0c28d24964b60e51fabaddda8"},
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.10.5", "9f3b1bee4a57f2269efea61e5efe55472683429b8a5bf1ebdd02d9748640f106", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, ">= 4.9.1 and < 5.0.0-0", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "3f25778d126c7e759444df0855077802c93299457afdf26566f8de6320ba56da"},
"ash_phoenix": {:hex, :ash_phoenix, "2.3.12", "34116f054ca4ef97b4badc73f028d78ee517692b713fd39f4c93f90bc2afd038", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "27394e40b44ca06977e90bd0b38bce7bf41c6dab9fe2aa0b474fdb7c0c1f911b"},
"ash_postgres": {:hex, :ash_postgres, "2.6.14", "8085b25864c63029a546ec7191d111f348405cb9d3a90677e52d805576319b55", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.2.72 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.14 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "0230343b959fb9cd24f76d1ecdbba90045d9625f03b33170ecb7c9ef011c9ac2"},
"ash_sql": {:hex, :ash_sql, "0.2.89", "ad4ad497263b586a7f3949ceea5d44620a36cb99a1ef0ff5f58f13a77d9b99ef", [:mix], [{:ash, ">= 3.5.25 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "bd957aee95bbdf6326fc7a9212f9a2ab87329b99ee3646c373a87bb3c9968566"},
"ash": {:hex, :ash, "3.7.1", "abb55dee19e0959e529e52fe0622468825ae05400f535484919713e492d9a9e7", [:mix], [{:crux, "~> 0.1.0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4474ce9befe9862d1ed73cadf8a755e836c45a14a7b3b952d02e1a12f2b2e529"},
"ash_admin": {:hex, :ash_admin, "0.13.19", "43227905381ea0b835039fb3f3d255a3664925619937869e605402bc2f95c5e5", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "41e6262c437164df6f052e43cc93be225a7e148b49a813fc451e70172338ee38"},
"ash_authentication": {:hex, :ash_authentication, "4.11.0", "4165ede37e179cb0a24b7bfc38d620fa93c05fb6272fbd353cafe27652b1e68b", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, ">= 3.4.29 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "~> 0.2.13", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "8201d0169944c1df3db9b560494e50e1c3bc99c3b1a8a2ef1e61b0f77bc820df"},
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.12.0", "75d7d77e3b626f3d8ea6ee44291d885950172ab399d997b2934f93d2e0a55a61", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "a423e22b40fdf3b1a7f2178e44ca68f48fdb5ba0d87e8d42a43de1a3b63ca704"},
"ash_phoenix": {:hex, :ash_phoenix, "2.3.17", "a074ae6d9d7135d99c4edc91ddebe4c035ca380b044592bf9c3d58471669cf52", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "94e4a6cc6ced31cddba930c45c1c3477aa59b956e7fc3cdc63095cf0e506bdf5"},
"ash_postgres": {:hex, :ash_postgres, "2.6.23", "5976a7e5e204b7bc627b1d17026bec9da4d880f2e09cd94bf4e8cee41fef32ce", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.7 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "61de4aedfe30f1ae14d8185cfc37a5b1940b45b60f2dfbdf9eb056f97dca41c5"},
"ash_sql": {:hex, :ash_sql, "0.3.7", "80affa5446075d71deb157c67290685a84b392d723be766bfb684f58fe0143de", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "ce4d974b8e784171c5a2a62593b3672b42dfd4888fa2239f01a6b32bad769038"},
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
"bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"},
"bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"},
"castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"},
"db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"},
"credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"},
"crux": {:hex, :crux, "0.1.1", "94f2f97d2a6079ae3c57f356412bc3b307f9579a80e43f526447b1d508dd4a72", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "e59d498f038193cbe31e448f9199f5b4c53a4c67cece9922bb839595189dd2b6"},
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.2", "7d0c0863f3fc8d71d17fc3ad3b9424beae13f02712ad84191a826c7169484f01", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669d9291370513ff56e7b7e7081b7af3283d02e046cf3d403053c557894a0b3e"},
"ecto": {:hex, :ecto, "3.13.3", "6a983f0917f8bdc7a89e96f2bf013f220503a0da5d8623224ba987515b3f0d80", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1927db768f53a88843ff25b6ba7946599a8ca8a055f69ad8058a1432a399af94"},
"ecto_commons": {:hex, :ecto_commons, "0.3.6", "7b1d9e59396cf8c8cbe5a26d50d03f9b6d0fe6c640210dd503622f276f1e59bb", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.2", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "3f12981a1e398f206c5d2014e7b732b7ec91b110b9cb84875cb5b28fc75d7a0a"},
"ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
@ -26,63 +27,65 @@
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
"ex_phone_number": {:hex, :ex_phone_number, "0.4.8", "c1c5e6f0673822a2a7b439b43af7d3eb1a5c19ae582b772b8b8d12625dd51ec1", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "43e2357c6b8cfe556bcd417f4ce9aaef267a786e31a2938902daaa0d36f69757"},
"expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"},
"file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
"fine": {:hex, :fine, "0.1.2", "85cf7dd190c7c6c54c2840754ae977c9acc0417316255b674fad9f2678e4ecc7", [:mix], [], "hexpm", "9113531982c2b60dbea6c7233917ddf16806947cd7104b5d03011bf436ca3072"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"igniter": {:hex, :igniter, "0.6.25", "e2774a4605c2bc9fc38f689232604aea0fc925c7966ae8e928fd9ea2fa9d300c", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "b1916e1e45796d5c371c7671305e81277231617eb58b1c120915aba237fbce6a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"igniter": {:hex, :igniter, "0.6.30", "83a466369ebb8fe009e0823c7bf04314dc545122c2d48f896172fc79df33e99d", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "76a14d5b7f850bb03b5243088c3649d54a2e52e34a2aa1104dee23cf50a8bae0"},
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
"jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"},
"lazy_html": {:hex, :lazy_html, "0.1.3", "8b9c8c135e95f7bc483de6195c4e1c0b2c913a5e2c57353ef4e82703b7ac8bd1", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "5f96f29587dcfed8a22281e8c44c6607e958ba821d90b9dfc003d1ef610f7d07"},
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
"live_debugger": {:hex, :live_debugger, "0.3.1", "4b4d36481c3b0a49ec082c8268d37974ece34d2091ac323ccc0c906eb0c0d032", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20.4 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "e50836495134b0dde98dc96919340749130ecb83618ea99d63a3a58ed1dcc27d"},
"live_debugger": {:hex, :live_debugger, "0.4.2", "775c3a570ef3c44d27d261b3c1aae23ef35cac949a57f67b3e7b1aa1fb2707bc", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "5b24e37985f0424056a322a18dab4a5fb0f4e8ee4e55975985364e0b45d683b9"},
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"owl": {:hex, :owl, "0.12.2", "65906b525e5c3ef51bab6cba7687152be017aebe1da077bb719a5ee9f7e60762", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "6398efa9e1fea70a04d24231e10dcd66c1ac1aa2da418d20ef5357ec61de2880"},
"phoenix": {:hex, :phoenix, "1.8.0-rc.4", "6c18c1e07938d3d8dbb957ed0d193fa591718a2997058f6883cfa7447f07612a", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "754c8caf0d1332bc691f826d678b192b3f78cfeb01df2f623683e308b363dc41"},
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
"phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"},
"phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.0", "2791fac0e2776b640192308cc90c0dbcf67843ad51387ed4ecae2038263d708d", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b3a1fa036d7eb2f956774eda7a7638cf5123f8f2175aca6d6420a7f95e598e1c"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.0-rc.4", "1e933da296a80c0f57689b25db8711fc47feb452ac5de4b4824e8e64bccae9f9", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4747a143c9b494b19f6ac58b919be46ff773066efe4882ee37ba0fd272f673c2"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.14", "cae84abc4cd00dde4bb200b8516db556704c585c267aff9cd4955ff83cceb86c", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b827980e2bc00fddd8674e3b567519a4e855b5de04bf8607140414f1101e2627"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
"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"},
"plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [: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", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"},
"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"},
"reactor": {:hex, :reactor, "0.15.6", "d717f9add549b25a089a94c90197718d2d838e35d81dd776b1d81587d4cf2aaa", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "74db98165e3644d86e0f723672d91ceca4339eaa935bcad7e78bf146a46d77b9"},
"reactor": {:hex, :reactor, "0.17.0", "eb8bdb530dbae824e2d36a8538f8ec4f3aa7c2d1b61b04959fa787c634f88b49", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3c3bf71693adbad9117b11ec83cfed7d5851b916ade508ed9718de7ae165bf25"},
"req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"},
"rewrite": {:hex, :rewrite, "1.1.2", "f5a5d10f5fed1491a6ff48e078d4585882695962ccc9e6c779bae025d1f92eda", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "7f8b94b1e3528d0a47b3e8b7bfeca559d2948a65fa7418a9ad7d7712703d39d4"},
"rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
"sobelow": {:hex, :sobelow, "0.14.0", "dd82aae8f72503f924fe9dd97ffe4ca694d2f17ec463dcfd365987c9752af6ee", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "7ecf91e298acfd9b24f5d761f19e8f6e6ac585b9387fb6301023f1f2cd5eed5f"},
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
"sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"},
"spark": {:hex, :spark, "2.2.67", "67626cb9f59ea4b1c5aa85d4afdd025e0740cbd49ed82665d0a40ff007d7fd4b", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "c8575402e3afc66871362e821bece890536d16319cdb758c5fb2d1250182e46f"},
"spark": {:hex, :spark, "2.3.5", "f30d30ecc3b4ab9b932d9aada66af7677fc1f297a2c349b0bcec3eafb9f996e8", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "0e9d339704d5d148f77f2b2fef3bcfc873a9e9bb4224fcf289c545d65827202f"},
"spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"},
"splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"},
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"swoosh": {:hex, :swoosh, "1.19.5", "5abd71be78302ba21be56a2b68d05c9946ff1f1bd254f949efef09d253b771ac", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c953f51ee0a8b237e0f4307c9cefd3eb1eb751c35fcdda2a8bccb991766473be"},
"tailwind": {:hex, :tailwind, "0.3.1", "a89d2835c580748c7a975ad7dd3f2ea5e63216dc16d44f9df492fbd12c094bed", [:mix], [], "hexpm", "98a45febdf4a87bc26682e1171acdedd6317d0919953c353fcd1b4f9f4b676a2"},
"swoosh": {:hex, :swoosh, "1.19.8", "0576f2ea96d1bb3a6e02cc9f79cbd7d497babc49a353eef8dce1a1f9f82d7915", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d7503c2daf0f9899afd8eba9923eeddef4b62e70816e1d3b6766e4d6c60e94ad"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
"thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"},
"tidewave": {:hex, :tidewave, "0.2.0", "e98378803e535d3035138e4b354dcfca26b7f862fd44cffef5aa697b814c0b0b", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.47 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "6ad11829f4600cd69955ffc66935e6456b775fea095172147244ba6f65986735"},
"thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
"tidewave": {:hex, :tidewave, "0.5.0", "8f278d7eb2d0af36ae6d4f73a5872bd066815bd57b57401125187ba901f095a4", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "9a1eb5d2f12ff4912328dfbfe652c27fded462c6ed6fd11814ee28d3e9d016b4"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
"yaml_elixir": {:hex, :yaml_elixir, "2.11.0", "9e9ccd134e861c66b84825a3542a1c22ba33f338d82c07282f4f1f52d847bd50", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "53cc28357ee7eb952344995787f4bb8cc3cecbf189652236e9b163e8ce1bc242"},
"yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"},
"ymlr": {:hex, :ymlr, "5.1.4", "b924d61e1fc1ec371cde6ab3ccd9311110b1e052fc5c2460fb322e8380e7712a", [:mix], [], "hexpm", "75f16cf0709fbd911b30311a0359a7aa4b5476346c01882addefd5f2b1cfaa51"},
}

64
priv/gettext/auth.pot Normal file
View file

@ -0,0 +1,64 @@
## This file is a PO Template file.
##
## "msgid"s here are often extracted from source code.
## Add new messages manually only if they're dynamic
## messages that can't be statically extracted.
##
## Leave "msgstr"s empty as changing them here has no
## effect: edit them in PO (.po) files instead.
#
msgid ""
msgstr ""
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Already have an account?"
msgstr ""
msgid "Email or password was incorrect"
msgstr ""
msgid "Email"
msgstr ""
msgid "Forgot your password?"
msgstr ""
msgid "If this user exists in our database you will contacted with a sign-in link shortly."
msgstr ""
msgid "If this user exists in our system, you will be contacted with reset instructions shortly."
msgstr ""
msgid "Need an account?"
msgstr ""
msgid "Password"
msgstr ""
msgid "Password Confirmation"
msgstr ""
msgid "Request magic link"
msgstr ""
msgid "Request password reset token"
msgstr ""
msgid "Requesting ..."
msgstr ""
msgid "Reset password with token"
msgstr ""
msgid "Sign in"
msgstr ""
msgid "Signing in ..."
msgstr ""
msgid "Your password has successfully been reset"
msgstr ""

View file

@ -0,0 +1,66 @@
## "msgid"s in this file come from POT (.pot) files.
###
### Do not add, change, or remove "msgid"s manually here as
### they're tied to the ones in the corresponding POT file
### (with the same domain).
###
### Use "mix gettext.extract --merge" or "mix gettext.merge"
### to merge POT files into PO files.
msgid ""
msgstr ""
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Already have an account?"
msgstr "Bereit zum Anmelden?"
msgid "Email or password was incorrect"
msgstr "Email oder Passwort nicht korrekt"
msgid "Email"
msgstr "Email"
msgid "Forgot your password?"
msgstr "Passwort vergessen?"
msgid "If this user exists in our database you will contacted with a sign-in link shortly."
msgstr "Falls diese*r Benutzer*in bekannt ist, wird jetzt eine Email mit Anmelde-Link versendet."
msgid "If this user exists in our system, you will be contacted with reset instructions shortly."
msgstr "Falls diese*r Benutzer*in bekannt ist, wird jetzt eine Email mit einer Anleitung zum Zurücksetzen versendet."
msgid "Need an account?"
msgstr "Konto anlegen?"
msgid "Password"
msgstr "Passwort"
msgid "Password Confirmation"
msgstr "Passwort Wiederholung"
msgid "Request magic link"
msgstr "Magischen Link anfordern"
msgid "Request password reset token"
msgstr "Passwort zurücksetzen"
msgid "Requesting ..."
msgstr "Anfrage låuft..."
msgid "Reset password with token"
msgstr "Neues Passwort setzen"
msgid "Sign in"
msgstr "Anmelden"
msgid "Signing in ..."
msgstr "Anmelden..."
msgid "Your password has successfully been reset"
msgstr "Das Passwort wurde erfolgreich zurückgesetzt"
#~ msgid "Sign in with Rauthy"
#~ msgstr "Anmelden mit der Vereinscloud"

View file

@ -15,69 +15,69 @@ msgstr ""
msgid "Actions"
msgstr "Aktionen"
#: lib/mv_web/live/member_live/index.html.heex:77
#: lib/mv_web/live/user_live/index.html.heex:69
#: lib/mv_web/live/member_live/index.html.heex:193
#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
msgstr "Bist du sicher?"
#: lib/mv_web/components/layouts.ex:71
#: lib/mv_web/components/layouts.ex:83
#: lib/mv_web/components/layouts.ex:80
#: lib/mv_web/components/layouts.ex:92
#, elixir-autogen, elixir-format
msgid "Attempting to reconnect"
msgstr "Verbindung wird wiederhergestellt"
#: lib/mv_web/live/member_live/form.ex:25
#: lib/mv_web/live/member_live/index.html.heex:62
#: lib/mv_web/live/member_live/index.html.heex:138
#: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format
msgid "City"
msgstr "Stadt"
#: lib/mv_web/live/member_live/index.html.heex:79
#: lib/mv_web/live/user_live/index.html.heex:71
#: lib/mv_web/live/member_live/index.html.heex:195
#: lib/mv_web/live/user_live/index.html.heex:67
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr "Löschen"
#: lib/mv_web/live/member_live/index.html.heex:71
#: lib/mv_web/live/member_live/index.html.heex:187
#: lib/mv_web/live/user_live/form.ex:109
#: lib/mv_web/live/user_live/index.html.heex:63
#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
msgid "Edit"
msgstr "Bearbeite"
#: lib/mv_web/live/member_live/show.ex:18
#: lib/mv_web/live/member_live/show.ex:81
#: lib/mv_web/live/member_live/show.ex:19
#: lib/mv_web/live/member_live/show.ex:95
#, elixir-autogen, elixir-format
msgid "Edit Member"
msgstr "Mitglied bearbeiten"
#: lib/mv_web/live/member_live/form.ex:18
#: lib/mv_web/live/member_live/index.html.heex:58
#: lib/mv_web/live/member_live/index.html.heex:70
#: lib/mv_web/live/member_live/show.ex:27
#: lib/mv_web/live/user_live/form.ex:14
#: lib/mv_web/live/user_live/index.html.heex:48
#: lib/mv_web/live/user_live/show.ex:24
#: lib/mv_web/live/user_live/index.html.heex:44
#: lib/mv_web/live/user_live/show.ex:25
#, elixir-autogen, elixir-format
msgid "Email"
msgstr "E-Mail"
#: lib/mv_web/live/member_live/form.ex:16
#: lib/mv_web/live/member_live/show.ex:25
#: lib/mv_web/live/member_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "First Name"
msgstr "Vorname"
#: lib/mv_web/live/member_live/form.ex:22
#: lib/mv_web/live/member_live/index.html.heex:64
#: lib/mv_web/live/member_live/index.html.heex:172
#: lib/mv_web/live/member_live/show.ex:33
#, elixir-autogen, elixir-format
msgid "Join Date"
msgstr "Beitrittsdatum"
#: lib/mv_web/live/member_live/form.ex:17
#: lib/mv_web/live/member_live/show.ex:26
#: lib/mv_web/live/member_live/show.ex:27
#, elixir-autogen, elixir-format
msgid "Last Name"
msgstr "Nachname"
@ -87,18 +87,18 @@ msgstr "Nachname"
msgid "New Member"
msgstr "Neues Mitglied"
#: lib/mv_web/live/member_live/index.html.heex:68
#: lib/mv_web/live/user_live/index.html.heex:60
#: lib/mv_web/live/member_live/index.html.heex:184
#: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format
msgid "Show"
msgstr "Anzeigen"
#: lib/mv_web/components/layouts.ex:78
#: lib/mv_web/components/layouts.ex:87
#, elixir-autogen, elixir-format
msgid "Something went wrong!"
msgstr "Etwas ist schiefgelaufen!"
#: lib/mv_web/components/layouts.ex:66
#: lib/mv_web/components/layouts.ex:75
#, elixir-autogen, elixir-format
msgid "We can't find the internet"
msgstr "Keine Internetverbindung gefunden"
@ -109,51 +109,51 @@ msgid "close"
msgstr "schließen"
#: lib/mv_web/live/member_live/form.ex:19
#: lib/mv_web/live/member_live/show.ex:28
#: lib/mv_web/live/member_live/show.ex:29
#, elixir-autogen, elixir-format
msgid "Birth Date"
msgstr "Geburtsdatum"
#: lib/mv_web/live/member_live/form.ex:30
#: lib/mv_web/live/member_live/show.ex:42
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Custom Properties"
msgstr "Eigene Eigenschaften"
#: lib/mv_web/live/member_live/form.ex:23
#: lib/mv_web/live/member_live/show.ex:34
#: lib/mv_web/live/member_live/show.ex:35
#, elixir-autogen, elixir-format
msgid "Exit Date"
msgstr "Austrittsdatum"
#: lib/mv_web/live/member_live/form.ex:27
#: lib/mv_web/live/member_live/index.html.heex:60
#: lib/mv_web/live/member_live/index.html.heex:104
#: lib/mv_web/live/member_live/show.ex:38
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr "Hausnummer"
#: lib/mv_web/live/member_live/form.ex:24
#: lib/mv_web/live/member_live/show.ex:35
#: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format
msgid "Notes"
msgstr "Notizen"
#: lib/mv_web/live/member_live/form.ex:20
#: lib/mv_web/live/member_live/show.ex:29
#: lib/mv_web/live/member_live/show.ex:30
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr "Bezahlt"
#: lib/mv_web/live/member_live/form.ex:21
#: lib/mv_web/live/member_live/index.html.heex:63
#: lib/mv_web/live/member_live/index.html.heex:155
#: lib/mv_web/live/member_live/show.ex:32
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr "Telefonnummer"
#: lib/mv_web/live/member_live/form.ex:28
#: lib/mv_web/live/member_live/index.html.heex:61
#: lib/mv_web/live/member_live/index.html.heex:121
#: lib/mv_web/live/member_live/show.ex:39
#, elixir-autogen, elixir-format
msgid "Postal Code"
@ -173,7 +173,7 @@ msgid "Saving..."
msgstr "Speichern..."
#: lib/mv_web/live/member_live/form.ex:26
#: lib/mv_web/live/member_live/index.html.heex:59
#: lib/mv_web/live/member_live/index.html.heex:87
#: lib/mv_web/live/member_live/show.ex:37
#, elixir-autogen, elixir-format
msgid "Street"
@ -184,17 +184,17 @@ msgstr "Straße"
msgid "Use this form to manage member records and their properties."
msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenschaften."
#: lib/mv_web/live/member_live/show.ex:24
#: lib/mv_web/live/member_live/show.ex:25
#, elixir-autogen, elixir-format
msgid "Id"
msgstr "ID"
#: lib/mv_web/live/member_live/show.ex:30
#: lib/mv_web/live/member_live/show.ex:31
#, elixir-autogen, elixir-format
msgid "No"
msgstr "Nein"
#: lib/mv_web/live/member_live/show.ex:80
#: lib/mv_web/live/member_live/show.ex:94
#, elixir-autogen, elixir-format, fuzzy
msgid "Show Member"
msgstr "Mitglied anzeigen"
@ -204,7 +204,7 @@ msgstr "Mitglied anzeigen"
msgid "This is a member record from your database."
msgstr "Dies ist ein Mitglied aus deiner Datenbank."
#: lib/mv_web/live/member_live/show.ex:30
#: lib/mv_web/live/member_live/show.ex:31
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr "Ja"
@ -281,17 +281,17 @@ msgstr "Eigenschaftstyp auswählen"
msgid "Description"
msgstr "Beschreibung"
#: lib/mv_web/live/user_live/show.ex:17
#: lib/mv_web/live/user_live/show.ex:18
#, elixir-autogen, elixir-format
msgid "Edit User"
msgstr "Benutzer bearbeiten"
msgstr "Benutzer*in bearbeiten"
#: lib/mv_web/live/user_live/show.ex:27
#: lib/mv_web/live/user_live/show.ex:28
#, elixir-autogen, elixir-format
msgid "Enabled"
msgstr "Aktiviert"
#: lib/mv_web/live/user_live/show.ex:23
#: lib/mv_web/live/user_live/show.ex:24
#, elixir-autogen, elixir-format
msgid "ID"
msgstr "ID"
@ -301,7 +301,7 @@ msgstr "ID"
msgid "Immutable"
msgstr "Unveränderlich"
#: lib/mv_web/components/layouts/navbar.ex:73
#: lib/mv_web/components/layouts/navbar.ex:94
#, elixir-autogen, elixir-format
msgid "Logout"
msgstr "Abmelden"
@ -310,7 +310,7 @@ msgstr "Abmelden"
#: lib/mv_web/live/user_live/index.html.heex:3
#, elixir-autogen, elixir-format
msgid "Listing Users"
msgstr "Benutzer auflisten"
msgstr "Benutzer*innen auflisten"
#: lib/mv_web/live/property_live/form.ex:27
#, elixir-autogen, elixir-format
@ -318,13 +318,12 @@ msgid "Member"
msgstr "Mitglied"
#: lib/mv_web/components/layouts/navbar.ex:14
#: lib/mv_web/live/member_live/index.ex:12
#: lib/mv_web/live/member_live/index.ex:8
#: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format
msgid "Members"
msgstr "Mitglieder"
#: lib/mv_web/live/member_live/index.html.heex:50
#: lib/mv_web/live/property_type_live/form.ex:16
#, elixir-autogen, elixir-format
msgid "Name"
@ -333,14 +332,14 @@ msgstr "Name"
#: lib/mv_web/live/user_live/index.html.heex:6
#, elixir-autogen, elixir-format
msgid "New User"
msgstr "Neuer Benutzer"
msgstr "Neue*r Benutzer*in"
#: lib/mv_web/live/user_live/show.ex:27
#: lib/mv_web/live/user_live/show.ex:28
#, elixir-autogen, elixir-format
msgid "Not enabled"
msgstr "Nicht aktiviert"
#: lib/mv_web/live/user_live/show.ex:25
#: lib/mv_web/live/user_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "Not set"
msgstr "Nicht gesetzt"
@ -351,13 +350,13 @@ msgstr "Nicht gesetzt"
msgid "Note"
msgstr "Hinweis"
#: lib/mv_web/live/user_live/index.html.heex:56
#: lib/mv_web/live/user_live/show.ex:25
#: lib/mv_web/live/user_live/index.html.heex:52
#: lib/mv_web/live/user_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "OIDC ID"
msgstr "OIDC ID"
#: lib/mv_web/live/user_live/show.ex:26
#: lib/mv_web/live/user_live/show.ex:27
#, elixir-autogen, elixir-format
msgid "Password Authentication"
msgstr "Passwort-Authentifizierung"
@ -367,15 +366,15 @@ msgstr "Passwort-Authentifizierung"
msgid "Please select a property type first"
msgstr "Bitte wählen Sie zuerst einen Eigenschaftstyp"
#: lib/mv_web/components/layouts/navbar.ex:69
#: lib/mv_web/components/layouts/navbar.ex:89
#, elixir-autogen, elixir-format
msgid "Profil"
msgstr "Profil"
#: lib/mv_web/live/property_live/form.ex:207
#, elixir-autogen, elixir-format, fuzzy
#, elixir-autogen, elixir-format
msgid "Property %{action} successfully"
msgstr "Mitglied %{action} erfolgreich"
msgstr "Eigenschaft %{action} erfolgreich"
#: lib/mv_web/live/property_live/form.ex:18
#, elixir-autogen, elixir-format
@ -402,17 +401,17 @@ msgstr "Eigenschaft speichern"
msgid "Save Property type"
msgstr "Eigenschaftstyp speichern"
#: lib/mv_web/live/member_live/index.html.heex:27
#: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr "Alle Mitglieder auswählen"
#: lib/mv_web/live/member_live/index.html.heex:41
#: lib/mv_web/live/member_live/index.html.heex:48
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr "Mitglied auswählen"
#: lib/mv_web/components/layouts/navbar.ex:72
#: lib/mv_web/components/layouts/navbar.ex:92
#, elixir-autogen, elixir-format
msgid "Settings"
msgstr "Einstellungen"
@ -420,17 +419,17 @@ msgstr "Einstellungen"
#: lib/mv_web/live/user_live/form.ex:93
#, elixir-autogen, elixir-format
msgid "Save User"
msgstr "Benutzer speichern"
msgstr "Benutzer*in speichern"
#: lib/mv_web/live/user_live/show.ex:38
#: lib/mv_web/live/user_live/show.ex:54
#, elixir-autogen, elixir-format
msgid "Show User"
msgstr "Benutzer anzeigen"
msgstr "Benutzer*in anzeigen"
#: lib/mv_web/live/user_live/show.ex:10
#, elixir-autogen, elixir-format
msgid "This is a user record from your database."
msgstr "Dies ist ein Benutzer-Datensatz aus Ihrer Datenbank."
msgstr "Dies ist ein Benutzer*innen-Datensatz aus Ihrer Datenbank."
#: lib/mv_web/live/property_live/form.ex:95
#, elixir-autogen, elixir-format
@ -438,25 +437,25 @@ msgid "Unsupported value type: %{type}"
msgstr "Nicht unterstützter Wertetyp: %{type}"
#: lib/mv_web/live/property_live/form.ex:10
#, elixir-autogen, elixir-format, fuzzy
#, elixir-autogen, elixir-format
msgid "Use this form to manage property records in your database."
msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenschaften."
msgstr "Dieses Formular dient zur Verwaltung von Eigenschaften in der Datenbank."
#: lib/mv_web/live/property_type_live/form.ex:11
#, elixir-autogen, elixir-format, fuzzy
#, elixir-autogen, elixir-format
msgid "Use this form to manage property_type records in your database."
msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenschaften."
msgstr "Dieses Formular dient zur Verwaltung von Eigenschaftstypen in der Datenbank."
#: lib/mv_web/live/user_live/form.ex:10
#, elixir-autogen, elixir-format
msgid "Use this form to manage user records in your database."
msgstr "Verwenden Sie dieses Formular, um Benutzer-Datensätze zu verwalten."
msgstr "Verwenden Sie dieses Formular, um Benutzer*innen-Datensätze zu verwalten."
#: lib/mv_web/live/user_live/form.ex:110
#: lib/mv_web/live/user_live/show.ex:9
#, elixir-autogen, elixir-format
msgid "User"
msgstr "Benutzer"
msgstr "Benutzer*in"
#: lib/mv_web/live/property_live/form.ex:59
#, elixir-autogen, elixir-format
@ -469,11 +468,13 @@ msgid "Value type"
msgstr "Wertetyp"
#: lib/mv_web/components/table_components.ex:30
#: lib/mv_web/live/components/sort_header_component.ex:55
#, elixir-autogen, elixir-format
msgid "ascending"
msgstr "aufsteigend"
#: lib/mv_web/components/table_components.ex:30
#: lib/mv_web/live/components/sort_header_component.ex:56
#, elixir-autogen, elixir-format
msgid "descending"
msgstr "absteigend"
@ -481,17 +482,17 @@ msgstr "absteigend"
#: lib/mv_web/live/user_live/form.ex:109
#, elixir-autogen, elixir-format
msgid "New"
msgstr "Neuer"
msgstr "Neue*r"
#: lib/mv_web/live/user_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "Admin Note"
msgstr "Administrator-Hinweis"
msgstr "Administrator*innen-Hinweis"
#: lib/mv_web/live/user_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
msgstr "Als Administrator können Sie direkt ein neues Passwort für diesen Benutzer setzen, wobei das gleiche sichere Ash Authentication System verwendet wird."
msgstr "Als Administrator*in können Sie direkt ein neues Passwort für diese*n Benutzer*in setzen, wobei das gleiche sichere Ash Authentication System verwendet wird."
#: lib/mv_web/live/user_live/form.ex:55
#, elixir-autogen, elixir-format
@ -506,7 +507,7 @@ msgstr "Passwort ändern"
#: lib/mv_web/live/user_live/form.ex:75
#, elixir-autogen, elixir-format
msgid "Check 'Change Password' above to set a new password for this user."
msgstr "Aktivieren Sie 'Passwort ändern' oben, um ein neues Passwort für diesen Benutzer zu setzen."
msgstr "Aktivieren Sie 'Passwort ändern' oben, um ein neues Passwort für diese*n Benutzer*in zu setzen."
#: lib/mv_web/live/user_live/form.ex:45
#, elixir-autogen, elixir-format
@ -533,15 +534,15 @@ msgstr "Passwort"
msgid "Password requirements"
msgstr "Passwort-Anforderungen"
#: lib/mv_web/live/user_live/index.html.heex:25
#: lib/mv_web/live/user_live/index.html.heex:21
#, elixir-autogen, elixir-format
msgid "Select all users"
msgstr "Alle Benutzer auswählen"
msgstr "Alle Benutzer*innen auswählen"
#: lib/mv_web/live/user_live/index.html.heex:39
#: lib/mv_web/live/user_live/index.html.heex:35
#, elixir-autogen, elixir-format
msgid "Select user"
msgstr "Benutzer auswählen"
msgstr "Benutzer*in auswählen"
#: lib/mv_web/live/user_live/form.ex:27
#, elixir-autogen, elixir-format
@ -551,4 +552,64 @@ msgstr "Passwort setzen"
#: lib/mv_web/live/user_live/form.ex:83
#, elixir-autogen, elixir-format
msgid "User will be created without a password. Check 'Set Password' to add one."
msgstr "Benutzer wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen', um eines hinzuzufügen."
msgstr "Benutzer*in wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen', um eines hinzuzufügen."
#: lib/mv_web/live/user_live/show.ex:30
#, elixir-autogen, elixir-format
msgid "Linked Member"
msgstr "Verknüpftes Mitglied"
#: lib/mv_web/live/member_live/show.ex:41
#, elixir-autogen, elixir-format
msgid "Linked User"
msgstr "Verknüpfte*r Benutzer*in"
#: lib/mv_web/live/user_live/show.ex:40
#, elixir-autogen, elixir-format
msgid "No member linked"
msgstr "Kein Mitglied verknüpft"
#: lib/mv_web/live/member_live/show.ex:51
#, elixir-autogen, elixir-format
msgid "No user linked"
msgstr "Keine*r Benutzer*in verknüpft"
#: lib/mv_web/live/member_live/show.ex:14
#: lib/mv_web/live/member_live/show.ex:16
#, elixir-autogen, elixir-format
msgid "Back to members list"
msgstr "Zurück zur Mitgliederliste"
#: lib/mv_web/live/user_live/show.ex:13
#: lib/mv_web/live/user_live/show.ex:15
#, elixir-autogen, elixir-format
msgid "Back to users list"
msgstr "Zurück zur Benutzer*innen-Liste"
#: lib/mv_web/components/layouts/navbar.ex:27
#: lib/mv_web/components/layouts/navbar.ex:33
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr "Sprache auswählen"
#: lib/mv_web/components/layouts/navbar.ex:40
#: lib/mv_web/components/layouts/navbar.ex:60
#, elixir-autogen, elixir-format
msgid "Toggle dark mode"
msgstr "Dunklen Modus umschalten"
#: lib/mv_web/live/components/search_bar_component.ex:15
#: lib/mv_web/live/member_live/index.html.heex:15
#, elixir-autogen, elixir-format
msgid "Click to sort"
msgstr "Klicke um zu sortieren"
#: lib/mv_web/live/member_live/index.html.heex:53
#, elixir-autogen, elixir-format, fuzzy
msgid "First name"
msgstr "Vorname"
#~ #: lib/mv_web/auth_overrides.ex:30
#~ #, elixir-autogen, elixir-format
#~ msgid "or"
#~ msgstr "oder"

View file

@ -12,101 +12,146 @@ msgstr ""
## From Ecto.Changeset.cast/4
msgid "can't be blank"
msgstr ""
msgstr "darf nicht leer sein"
## From Ecto.Changeset.unique_constraint/3
msgid "has already been taken"
msgstr ""
msgstr "ist bereits vergeben"
## From Ecto.Changeset.put_change/3
msgid "is invalid"
msgstr ""
msgstr "ist ungültig"
## From Ecto.Changeset.validate_acceptance/3
msgid "must be accepted"
msgstr ""
msgstr "muss akzeptiert werden"
## From Ecto.Changeset.validate_format/3
msgid "has invalid format"
msgstr ""
msgstr "hat ein ungültiges Format"
## From Ecto.Changeset.validate_subset/3
msgid "has an invalid entry"
msgstr ""
msgstr "hat einen ungültigen Eintrag"
## From Ecto.Changeset.validate_exclusion/3
msgid "is reserved"
msgstr ""
msgstr "ist reserviert"
## From Ecto.Changeset.validate_confirmation/3
msgid "does not match confirmation"
msgstr ""
msgstr "stimmt nicht mit der Bestätigung überein"
## From Ecto.Changeset.no_assoc_constraint/3
msgid "is still associated with this entry"
msgstr ""
msgstr "ist noch mit diesem Eintrag verknüpft"
msgid "are still associated with this entry"
msgstr ""
msgstr "sind noch mit diesem Eintrag verknüpft"
## From Ecto.Changeset.validate_length/3
msgid "should have %{count} item(s)"
msgid_plural "should have %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgstr[0] "sollte %{count} Element haben"
msgstr[1] "sollte %{count} Elemente haben"
msgid "should be %{count} character(s)"
msgid_plural "should be %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgstr[0] "sollte %{count} Zeichen haben"
msgstr[1] "sollte %{count} Zeichen haben"
msgid "should be %{count} byte(s)"
msgid_plural "should be %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgstr[0] "sollte %{count} Byte haben"
msgstr[1] "sollte %{count} Bytes haben"
msgid "should have at least %{count} item(s)"
msgid_plural "should have at least %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgstr[0] "sollte mindestens %{count} Element haben"
msgstr[1] "sollte mindestens %{count} Elemente haben"
msgid "should be at least %{count} character(s)"
msgid_plural "should be at least %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgstr[0] "sollte mindestens %{count} Zeichen haben"
msgstr[1] "sollte mindestens %{count} Zeichen haben"
msgid "should be at least %{count} byte(s)"
msgid_plural "should be at least %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgstr[0] "sollte mindestens %{count} Byte haben"
msgstr[1] "sollte mindestens %{count} Bytes haben"
msgid "should have at most %{count} item(s)"
msgid_plural "should have at most %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgstr[0] "sollte höchstens %{count} Element haben"
msgstr[1] "sollte höchstens %{count} Elemente haben"
msgid "should be at most %{count} character(s)"
msgid_plural "should be at most %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgstr[0] "sollte höchstens %{count} Zeichen haben"
msgstr[1] "sollte höchstens %{count} Zeichen haben"
msgid "should be at most %{count} byte(s)"
msgid_plural "should be at most %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgstr[0] "sollte höchstens %{count} Byte haben"
msgstr[1] "sollte höchstens %{count} Bytes haben"
## From Ecto.Changeset.validate_number/3
msgid "must be less than %{number}"
msgstr ""
msgstr "muss kleiner als %{number} sein"
msgid "must be greater than %{number}"
msgstr ""
msgstr "muss größer als %{number} sein"
msgid "must be less than or equal to %{number}"
msgstr ""
msgstr "muss kleiner oder gleich %{number} sein"
msgid "must be greater than or equal to %{number}"
msgstr ""
msgstr "muss größer oder gleich %{number} sein"
msgid "must be equal to %{number}"
msgstr ""
msgstr "muss gleich %{number} sein"
## Ash Framework - Standard constraint messages
msgid "length must be greater than or equal to %{min}"
msgstr "muss mindestens %{min} Zeichen lang sein"
msgid "length must be less than or equal to %{max}"
msgstr "darf höchstens %{max} Zeichen lang sein"
msgid "must be present"
msgstr "muss vorhanden sein"
## Custom validation messages from Mv.Accounts.User
msgid "User already has a member. Remove existing member first."
msgstr "Benutzer*in hat bereits ein Mitglied. Entfernen Sie zuerst das vorhandene Mitglied."
msgid "OIDC user_info must contain a non-empty 'sub' or 'id' field"
msgstr "OIDC user_info darf kein leeres 'sub' oder 'id' Feld enthalten"
## Custom validation messages from Mv.Membership.Member
msgid "User is already linked to another member"
msgstr "Benutzer*in ist bereits mit einem anderen Mitglied verknüpft"
msgid "User not found"
msgstr "Benutzer*in nicht gefunden"
msgid "cannot be in the future"
msgstr "darf nicht in der Zukunft liegen"
msgid "cannot be before join date"
msgstr "darf nicht vor dem Beitrittsdatum liegen"
msgid "is not a valid phone number"
msgstr "ist keine gültige Telefonnummer"
msgid "must consist of 5 digits"
msgstr "muss aus 5 Ziffern bestehen"
msgid "is not a valid email"
msgstr "ist keine gültige E-Mail-Adresse"
msgid "must have length of at least 8"
msgstr "muss mindestens 8 Zeichen lang sein"
msgid "is required"
msgstr "ist erforderlich"

View file

@ -16,69 +16,69 @@ msgstr ""
msgid "Actions"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:77
#: lib/mv_web/live/user_live/index.html.heex:69
#: lib/mv_web/live/member_live/index.html.heex:193
#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
msgstr ""
#: lib/mv_web/components/layouts.ex:71
#: lib/mv_web/components/layouts.ex:83
#: lib/mv_web/components/layouts.ex:80
#: lib/mv_web/components/layouts.ex:92
#, elixir-autogen, elixir-format
msgid "Attempting to reconnect"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:25
#: lib/mv_web/live/member_live/index.html.heex:62
#: lib/mv_web/live/member_live/index.html.heex:138
#: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format
msgid "City"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:79
#: lib/mv_web/live/user_live/index.html.heex:71
#: lib/mv_web/live/member_live/index.html.heex:195
#: lib/mv_web/live/user_live/index.html.heex:67
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:71
#: lib/mv_web/live/member_live/index.html.heex:187
#: lib/mv_web/live/user_live/form.ex:109
#: lib/mv_web/live/user_live/index.html.heex:63
#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
msgid "Edit"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:18
#: lib/mv_web/live/member_live/show.ex:81
#: lib/mv_web/live/member_live/show.ex:19
#: lib/mv_web/live/member_live/show.ex:95
#, elixir-autogen, elixir-format
msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:18
#: lib/mv_web/live/member_live/index.html.heex:58
#: lib/mv_web/live/member_live/index.html.heex:70
#: lib/mv_web/live/member_live/show.ex:27
#: lib/mv_web/live/user_live/form.ex:14
#: lib/mv_web/live/user_live/index.html.heex:48
#: lib/mv_web/live/user_live/show.ex:24
#: lib/mv_web/live/user_live/index.html.heex:44
#: lib/mv_web/live/user_live/show.ex:25
#, elixir-autogen, elixir-format
msgid "Email"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:16
#: lib/mv_web/live/member_live/show.ex:25
#: lib/mv_web/live/member_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "First Name"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:22
#: lib/mv_web/live/member_live/index.html.heex:64
#: lib/mv_web/live/member_live/index.html.heex:172
#: lib/mv_web/live/member_live/show.ex:33
#, elixir-autogen, elixir-format
msgid "Join Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:17
#: lib/mv_web/live/member_live/show.ex:26
#: lib/mv_web/live/member_live/show.ex:27
#, elixir-autogen, elixir-format
msgid "Last Name"
msgstr ""
@ -88,18 +88,18 @@ msgstr ""
msgid "New Member"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:68
#: lib/mv_web/live/user_live/index.html.heex:60
#: lib/mv_web/live/member_live/index.html.heex:184
#: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format
msgid "Show"
msgstr ""
#: lib/mv_web/components/layouts.ex:78
#: lib/mv_web/components/layouts.ex:87
#, elixir-autogen, elixir-format
msgid "Something went wrong!"
msgstr ""
#: lib/mv_web/components/layouts.ex:66
#: lib/mv_web/components/layouts.ex:75
#, elixir-autogen, elixir-format
msgid "We can't find the internet"
msgstr ""
@ -110,51 +110,51 @@ msgid "close"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:19
#: lib/mv_web/live/member_live/show.ex:28
#: lib/mv_web/live/member_live/show.ex:29
#, elixir-autogen, elixir-format
msgid "Birth Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:30
#: lib/mv_web/live/member_live/show.ex:42
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Custom Properties"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:23
#: lib/mv_web/live/member_live/show.ex:34
#: lib/mv_web/live/member_live/show.ex:35
#, elixir-autogen, elixir-format
msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:27
#: lib/mv_web/live/member_live/index.html.heex:60
#: lib/mv_web/live/member_live/index.html.heex:104
#: lib/mv_web/live/member_live/show.ex:38
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:24
#: lib/mv_web/live/member_live/show.ex:35
#: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format
msgid "Notes"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:20
#: lib/mv_web/live/member_live/show.ex:29
#: lib/mv_web/live/member_live/show.ex:30
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:21
#: lib/mv_web/live/member_live/index.html.heex:63
#: lib/mv_web/live/member_live/index.html.heex:155
#: lib/mv_web/live/member_live/show.ex:32
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:28
#: lib/mv_web/live/member_live/index.html.heex:61
#: lib/mv_web/live/member_live/index.html.heex:121
#: lib/mv_web/live/member_live/show.ex:39
#, elixir-autogen, elixir-format
msgid "Postal Code"
@ -174,7 +174,7 @@ msgid "Saving..."
msgstr ""
#: lib/mv_web/live/member_live/form.ex:26
#: lib/mv_web/live/member_live/index.html.heex:59
#: lib/mv_web/live/member_live/index.html.heex:87
#: lib/mv_web/live/member_live/show.ex:37
#, elixir-autogen, elixir-format
msgid "Street"
@ -185,17 +185,17 @@ msgstr ""
msgid "Use this form to manage member records and their properties."
msgstr ""
#: lib/mv_web/live/member_live/show.ex:24
#: lib/mv_web/live/member_live/show.ex:25
#, elixir-autogen, elixir-format
msgid "Id"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:30
#: lib/mv_web/live/member_live/show.ex:31
#, elixir-autogen, elixir-format
msgid "No"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:80
#: lib/mv_web/live/member_live/show.ex:94
#, elixir-autogen, elixir-format
msgid "Show Member"
msgstr ""
@ -205,7 +205,7 @@ msgstr ""
msgid "This is a member record from your database."
msgstr ""
#: lib/mv_web/live/member_live/show.ex:30
#: lib/mv_web/live/member_live/show.ex:31
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr ""
@ -282,17 +282,17 @@ msgstr ""
msgid "Description"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:17
#: lib/mv_web/live/user_live/show.ex:18
#, elixir-autogen, elixir-format
msgid "Edit User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:27
#: lib/mv_web/live/user_live/show.ex:28
#, elixir-autogen, elixir-format
msgid "Enabled"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:23
#: lib/mv_web/live/user_live/show.ex:24
#, elixir-autogen, elixir-format
msgid "ID"
msgstr ""
@ -302,7 +302,7 @@ msgstr ""
msgid "Immutable"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:73
#: lib/mv_web/components/layouts/navbar.ex:94
#, elixir-autogen, elixir-format
msgid "Logout"
msgstr ""
@ -319,13 +319,12 @@ msgid "Member"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:14
#: lib/mv_web/live/member_live/index.ex:12
#: lib/mv_web/live/member_live/index.ex:8
#: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format
msgid "Members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:50
#: lib/mv_web/live/property_type_live/form.ex:16
#, elixir-autogen, elixir-format
msgid "Name"
@ -336,12 +335,12 @@ msgstr ""
msgid "New User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:27
#: lib/mv_web/live/user_live/show.ex:28
#, elixir-autogen, elixir-format
msgid "Not enabled"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:25
#: lib/mv_web/live/user_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "Not set"
msgstr ""
@ -352,13 +351,13 @@ msgstr ""
msgid "Note"
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex:56
#: lib/mv_web/live/user_live/show.ex:25
#: lib/mv_web/live/user_live/index.html.heex:52
#: lib/mv_web/live/user_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "OIDC ID"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:26
#: lib/mv_web/live/user_live/show.ex:27
#, elixir-autogen, elixir-format
msgid "Password Authentication"
msgstr ""
@ -368,7 +367,7 @@ msgstr ""
msgid "Please select a property type first"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:69
#: lib/mv_web/components/layouts/navbar.ex:89
#, elixir-autogen, elixir-format
msgid "Profil"
msgstr ""
@ -403,17 +402,17 @@ msgstr ""
msgid "Save Property type"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:27
#: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:41
#: lib/mv_web/live/member_live/index.html.heex:48
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:72
#: lib/mv_web/components/layouts/navbar.ex:92
#, elixir-autogen, elixir-format
msgid "Settings"
msgstr ""
@ -423,7 +422,7 @@ msgstr ""
msgid "Save User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:38
#: lib/mv_web/live/user_live/show.ex:54
#, elixir-autogen, elixir-format
msgid "Show User"
msgstr ""
@ -470,11 +469,13 @@ msgid "Value type"
msgstr ""
#: lib/mv_web/components/table_components.ex:30
#: lib/mv_web/live/components/sort_header_component.ex:55
#, elixir-autogen, elixir-format
msgid "ascending"
msgstr ""
#: lib/mv_web/components/table_components.ex:30
#: lib/mv_web/live/components/sort_header_component.ex:56
#, elixir-autogen, elixir-format
msgid "descending"
msgstr ""
@ -534,12 +535,12 @@ msgstr ""
msgid "Password requirements"
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex:25
#: lib/mv_web/live/user_live/index.html.heex:21
#, elixir-autogen, elixir-format
msgid "Select all users"
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex:39
#: lib/mv_web/live/user_live/index.html.heex:35
#, elixir-autogen, elixir-format
msgid "Select user"
msgstr ""
@ -553,3 +554,66 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "User will be created without a password. Check 'Set Password' to add one."
msgstr ""
#: lib/mv_web/live/user_live/show.ex:30
#, elixir-autogen, elixir-format
msgid "Linked Member"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:41
#, elixir-autogen, elixir-format
msgid "Linked User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:40
#, elixir-autogen, elixir-format
msgid "No member linked"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:51
#, elixir-autogen, elixir-format
msgid "No user linked"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:14
#: lib/mv_web/live/member_live/show.ex:16
#, elixir-autogen, elixir-format
msgid "Back to members list"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:13
#: lib/mv_web/live/user_live/show.ex:15
#, elixir-autogen, elixir-format
msgid "Back to users list"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:27
#: lib/mv_web/components/layouts/navbar.ex:33
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:40
#: lib/mv_web/components/layouts/navbar.ex:60
#, elixir-autogen, elixir-format
msgid "Toggle dark mode"
msgstr ""
#: lib/mv_web/live/components/search_bar_component.ex:15
#: lib/mv_web/live/member_live/index.html.heex:15
#, elixir-autogen, elixir-format
msgid "Search..."
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:20
#, elixir-autogen, elixir-format
msgid "Users"
#: lib/mv_web/live/components/sort_header_component.ex:60
#, elixir-autogen, elixir-format
msgid "Click to sort"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:53
#, elixir-autogen, elixir-format
msgid "First name"
msgstr ""

View file

@ -0,0 +1,63 @@
## "msgid"s in this file come from POT (.pot) files.
###
### Do not add, change, or remove "msgid"s manually here as
### they're tied to the ones in the corresponding POT file
### (with the same domain).
###
### Use "mix gettext.extract --merge" or "mix gettext.merge"
### to merge POT files into PO files.
msgid ""
msgstr ""
"Language: en\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Already have an account?"
msgstr ""
msgid "Email or password was incorrect"
msgstr ""
msgid "Email"
msgstr ""
msgid "Forgot your password?"
msgstr ""
msgid "If this user exists in our database you will contacted with a sign-in link shortly."
msgstr ""
msgid "If this user exists in our system, you will be contacted with reset instructions shortly."
msgstr ""
msgid "Need an account?"
msgstr ""
msgid "Password"
msgstr ""
msgid "Password Confirmation"
msgstr ""
msgid "Request magic link"
msgstr ""
msgid "Request password reset token"
msgstr ""
msgid "Requesting ..."
msgstr ""
msgid "Reset password with token"
msgstr ""
msgid "Sign in"
msgstr ""
msgid "Signing in ..."
msgstr ""
msgid "Your password has successfully been reset"
msgstr ""
#~ msgid "Sign in with Rauthy"
#~ msgstr "Sign in with Vereinscloud"

View file

@ -16,69 +16,69 @@ msgstr ""
msgid "Actions"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:77
#: lib/mv_web/live/user_live/index.html.heex:69
#: lib/mv_web/live/member_live/index.html.heex:193
#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
msgstr ""
#: lib/mv_web/components/layouts.ex:71
#: lib/mv_web/components/layouts.ex:83
#: lib/mv_web/components/layouts.ex:80
#: lib/mv_web/components/layouts.ex:92
#, elixir-autogen, elixir-format
msgid "Attempting to reconnect"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:25
#: lib/mv_web/live/member_live/index.html.heex:62
#: lib/mv_web/live/member_live/index.html.heex:138
#: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format
msgid "City"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:79
#: lib/mv_web/live/user_live/index.html.heex:71
#: lib/mv_web/live/member_live/index.html.heex:195
#: lib/mv_web/live/user_live/index.html.heex:67
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:71
#: lib/mv_web/live/member_live/index.html.heex:187
#: lib/mv_web/live/user_live/form.ex:109
#: lib/mv_web/live/user_live/index.html.heex:63
#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
msgid "Edit"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:18
#: lib/mv_web/live/member_live/show.ex:81
#: lib/mv_web/live/member_live/show.ex:19
#: lib/mv_web/live/member_live/show.ex:95
#, elixir-autogen, elixir-format
msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:18
#: lib/mv_web/live/member_live/index.html.heex:58
#: lib/mv_web/live/member_live/index.html.heex:70
#: lib/mv_web/live/member_live/show.ex:27
#: lib/mv_web/live/user_live/form.ex:14
#: lib/mv_web/live/user_live/index.html.heex:48
#: lib/mv_web/live/user_live/show.ex:24
#: lib/mv_web/live/user_live/index.html.heex:44
#: lib/mv_web/live/user_live/show.ex:25
#, elixir-autogen, elixir-format
msgid "Email"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:16
#: lib/mv_web/live/member_live/show.ex:25
#: lib/mv_web/live/member_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "First Name"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:22
#: lib/mv_web/live/member_live/index.html.heex:64
#: lib/mv_web/live/member_live/index.html.heex:172
#: lib/mv_web/live/member_live/show.ex:33
#, elixir-autogen, elixir-format
msgid "Join Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:17
#: lib/mv_web/live/member_live/show.ex:26
#: lib/mv_web/live/member_live/show.ex:27
#, elixir-autogen, elixir-format
msgid "Last Name"
msgstr ""
@ -88,18 +88,18 @@ msgstr ""
msgid "New Member"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:68
#: lib/mv_web/live/user_live/index.html.heex:60
#: lib/mv_web/live/member_live/index.html.heex:184
#: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format
msgid "Show"
msgstr ""
#: lib/mv_web/components/layouts.ex:78
#: lib/mv_web/components/layouts.ex:87
#, elixir-autogen, elixir-format
msgid "Something went wrong!"
msgstr ""
#: lib/mv_web/components/layouts.ex:66
#: lib/mv_web/components/layouts.ex:75
#, elixir-autogen, elixir-format
msgid "We can't find the internet"
msgstr ""
@ -110,51 +110,51 @@ msgid "close"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:19
#: lib/mv_web/live/member_live/show.ex:28
#: lib/mv_web/live/member_live/show.ex:29
#, elixir-autogen, elixir-format
msgid "Birth Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:30
#: lib/mv_web/live/member_live/show.ex:42
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Custom Properties"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:23
#: lib/mv_web/live/member_live/show.ex:34
#: lib/mv_web/live/member_live/show.ex:35
#, elixir-autogen, elixir-format
msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:27
#: lib/mv_web/live/member_live/index.html.heex:60
#: lib/mv_web/live/member_live/index.html.heex:104
#: lib/mv_web/live/member_live/show.ex:38
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:24
#: lib/mv_web/live/member_live/show.ex:35
#: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format
msgid "Notes"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:20
#: lib/mv_web/live/member_live/show.ex:29
#: lib/mv_web/live/member_live/show.ex:30
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:21
#: lib/mv_web/live/member_live/index.html.heex:63
#: lib/mv_web/live/member_live/index.html.heex:155
#: lib/mv_web/live/member_live/show.ex:32
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:28
#: lib/mv_web/live/member_live/index.html.heex:61
#: lib/mv_web/live/member_live/index.html.heex:121
#: lib/mv_web/live/member_live/show.ex:39
#, elixir-autogen, elixir-format
msgid "Postal Code"
@ -174,7 +174,7 @@ msgid "Saving..."
msgstr ""
#: lib/mv_web/live/member_live/form.ex:26
#: lib/mv_web/live/member_live/index.html.heex:59
#: lib/mv_web/live/member_live/index.html.heex:87
#: lib/mv_web/live/member_live/show.ex:37
#, elixir-autogen, elixir-format
msgid "Street"
@ -185,17 +185,17 @@ msgstr ""
msgid "Use this form to manage member records and their properties."
msgstr ""
#: lib/mv_web/live/member_live/show.ex:24
#: lib/mv_web/live/member_live/show.ex:25
#, elixir-autogen, elixir-format
msgid "Id"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:30
#: lib/mv_web/live/member_live/show.ex:31
#, elixir-autogen, elixir-format
msgid "No"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:80
#: lib/mv_web/live/member_live/show.ex:94
#, elixir-autogen, elixir-format, fuzzy
msgid "Show Member"
msgstr ""
@ -205,7 +205,7 @@ msgstr ""
msgid "This is a member record from your database."
msgstr ""
#: lib/mv_web/live/member_live/show.ex:30
#: lib/mv_web/live/member_live/show.ex:31
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr ""
@ -282,17 +282,17 @@ msgstr ""
msgid "Description"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:17
#: lib/mv_web/live/user_live/show.ex:18
#, elixir-autogen, elixir-format, fuzzy
msgid "Edit User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:27
#: lib/mv_web/live/user_live/show.ex:28
#, elixir-autogen, elixir-format
msgid "Enabled"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:23
#: lib/mv_web/live/user_live/show.ex:24
#, elixir-autogen, elixir-format
msgid "ID"
msgstr ""
@ -302,7 +302,7 @@ msgstr ""
msgid "Immutable"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:73
#: lib/mv_web/components/layouts/navbar.ex:94
#, elixir-autogen, elixir-format
msgid "Logout"
msgstr ""
@ -319,13 +319,12 @@ msgid "Member"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:14
#: lib/mv_web/live/member_live/index.ex:12
#: lib/mv_web/live/member_live/index.ex:8
#: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format
msgid "Members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:50
#: lib/mv_web/live/property_type_live/form.ex:16
#, elixir-autogen, elixir-format
msgid "Name"
@ -336,12 +335,12 @@ msgstr ""
msgid "New User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:27
#: lib/mv_web/live/user_live/show.ex:28
#, elixir-autogen, elixir-format
msgid "Not enabled"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:25
#: lib/mv_web/live/user_live/show.ex:26
#, elixir-autogen, elixir-format, fuzzy
msgid "Not set"
msgstr ""
@ -352,13 +351,13 @@ msgstr ""
msgid "Note"
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex:56
#: lib/mv_web/live/user_live/show.ex:25
#: lib/mv_web/live/user_live/index.html.heex:52
#: lib/mv_web/live/user_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "OIDC ID"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:26
#: lib/mv_web/live/user_live/show.ex:27
#, elixir-autogen, elixir-format
msgid "Password Authentication"
msgstr ""
@ -368,7 +367,7 @@ msgstr ""
msgid "Please select a property type first"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:69
#: lib/mv_web/components/layouts/navbar.ex:89
#, elixir-autogen, elixir-format
msgid "Profil"
msgstr ""
@ -403,17 +402,17 @@ msgstr ""
msgid "Save Property type"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:27
#: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:41
#: lib/mv_web/live/member_live/index.html.heex:48
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:72
#: lib/mv_web/components/layouts/navbar.ex:92
#, elixir-autogen, elixir-format
msgid "Settings"
msgstr ""
@ -423,7 +422,7 @@ msgstr ""
msgid "Save User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:38
#: lib/mv_web/live/user_live/show.ex:54
#, elixir-autogen, elixir-format, fuzzy
msgid "Show User"
msgstr ""
@ -470,11 +469,13 @@ msgid "Value type"
msgstr ""
#: lib/mv_web/components/table_components.ex:30
#: lib/mv_web/live/components/sort_header_component.ex:55
#, elixir-autogen, elixir-format
msgid "ascending"
msgstr ""
#: lib/mv_web/components/table_components.ex:30
#: lib/mv_web/live/components/sort_header_component.ex:56
#, elixir-autogen, elixir-format
msgid "descending"
msgstr ""
@ -534,12 +535,12 @@ msgstr "Password"
msgid "Password requirements"
msgstr "Password requirements"
#: lib/mv_web/live/user_live/index.html.heex:25
#: lib/mv_web/live/user_live/index.html.heex:21
#, elixir-autogen, elixir-format, fuzzy
msgid "Select all users"
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex:39
#: lib/mv_web/live/user_live/index.html.heex:35
#, elixir-autogen, elixir-format, fuzzy
msgid "Select user"
msgstr ""
@ -553,3 +554,18 @@ msgstr "Set Password"
#, elixir-autogen, elixir-format
msgid "User will be created without a password. Check 'Set Password' to add one."
msgstr "User will be created without a password. Check 'Set Password' to add one."
#: lib/mv_web/live/components/sort_header_component.ex:60
#, elixir-autogen, elixir-format
msgid "Click to sort"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:53
#, elixir-autogen, elixir-format, fuzzy
msgid "First name"
msgstr ""
#~ #: lib/mv_web/auth_overrides.ex:30
#~ #, elixir-autogen, elixir-format
#~ msgid "or"
#~ msgstr ""

View file

@ -110,3 +110,48 @@ msgstr ""
msgid "must be equal to %{number}"
msgstr ""
## Ash Framework - Standard constraint messages
msgid "length must be greater than or equal to %{min}"
msgstr ""
msgid "length must be less than or equal to %{max}"
msgstr ""
msgid "must be present"
msgstr ""
## Custom validation messages from Mv.Accounts.User
msgid "User already has a member. Remove existing member first."
msgstr ""
msgid "OIDC user_info must contain a non-empty 'sub' or 'id' field"
msgstr ""
## Custom validation messages from Mv.Membership.Member
msgid "User is already linked to another member"
msgstr ""
msgid "User not found"
msgstr ""
msgid "cannot be in the future"
msgstr ""
msgid "cannot be before join date"
msgstr ""
msgid "is not a valid phone number"
msgstr ""
msgid "must consist of 5 digits"
msgstr ""
msgid "is not a valid email"
msgstr ""
msgid "must have length of at least 8"
msgstr ""
msgid "is required"
msgstr ""

View file

@ -107,3 +107,48 @@ msgstr ""
msgid "must be equal to %{number}"
msgstr ""
## Ash Framework - Standard constraint messages
msgid "length must be greater than or equal to %{min}"
msgstr ""
msgid "length must be less than or equal to %{max}"
msgstr ""
msgid "must be present"
msgstr ""
## Custom validation messages from Mv.Accounts.User
msgid "User already has a member. Remove existing member first."
msgstr ""
msgid "OIDC user_info must contain a non-empty 'sub' or 'id' field"
msgstr ""
## Custom validation messages from Mv.Membership.Member
msgid "User is already linked to another member"
msgstr ""
msgid "User not found"
msgstr ""
msgid "cannot be in the future"
msgstr ""
msgid "cannot be before join date"
msgstr ""
msgid "is not a valid phone number"
msgstr ""
msgid "must consist of 5 digits"
msgstr ""
msgid "is not a valid email"
msgstr ""
msgid "must have length of at least 8"
msgstr ""
msgid "is required"
msgstr ""

View file

@ -0,0 +1,60 @@
defmodule Mv.Repo.Migrations.AddSearchVectorToMembers do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:members) do
add :search_vector, :tsvector
end
execute("""
CREATE INDEX members_search_vector_idx
ON members
USING GIN (search_vector)
""")
# Eigene Trigger-Funktion mit Gewichtung
execute("""
CREATE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.birth_date::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
""")
execute("""
CREATE TRIGGER update_search_vector
BEFORE INSERT OR UPDATE ON members
FOR EACH ROW
EXECUTE FUNCTION members_search_vector_trigger()
""")
end
def down do
execute("DROP TRIGGER IF EXISTS update_search_vector ON members")
execute("DROP FUNCTION IF EXISTS members_search_vector_trigger()")
execute("DROP INDEX IF EXISTS members_search_vector_idx")
alter table(:members) do
remove :search_vector
end
end
end

View file

@ -0,0 +1,19 @@
defmodule Mv.Repo.Migrations.MemberRelation do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
# Ensure 1:1 relationship - one user can only be linked to one member
# This prevents multiple users from sharing the same member account
create unique_index(:users, [:member_id], name: "users_unique_member_index")
end
def down do
drop_if_exists unique_index(:users, [:member_id], name: "users_unique_member_index")
end
end

View file

@ -0,0 +1,19 @@
defmodule Mv.Repo.Migrations.AddUniqueEmailToMembers do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
# Ensure email uniqueness across all members
# This supports upsert operations and prevents duplicate member accounts
create unique_index(:members, [:email], name: "members_unique_email_index")
end
def down do
drop_if_exists unique_index(:members, [:email], name: "members_unique_email_index")
end
end

View file

@ -0,0 +1,46 @@
defmodule Mv.Repo.Migrations.AddConstraintsForUserMemberAndProperty do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
drop constraint(:users, "users_member_id_fkey")
alter table(:users) do
modify :member_id,
references(:members,
column: :id,
name: "users_member_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :nilify_all
)
end
create unique_index(:properties, [:member_id, :property_type_id],
name: "properties_unique_property_per_member_index"
)
end
def down do
drop_if_exists unique_index(:properties, [:member_id, :property_type_id],
name: "properties_unique_property_per_member_index"
)
drop constraint(:users, "users_member_id_fkey")
alter table(:users) do
modify :member_id,
references(:members,
column: :id,
name: "users_member_id_fkey",
type: :uuid,
prefix: "public"
)
end
end
end

View file

@ -47,3 +47,151 @@ end
Accounts.create_user!(%{email: "admin@mv.local"}, upsert?: true, upsert_identity: :unique_email)
|> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"})
|> Ash.update!()
# Create sample members for testing - use upsert to prevent duplicates
for member_attrs <- [
%{
first_name: "Hans",
last_name: "Müller",
email: "hans.mueller@example.de",
birth_date: ~D[1985-06-15],
join_date: ~D[2023-01-15],
paid: true,
phone_number: "+49301234567",
city: "München",
street: "Hauptstraße",
house_number: "42",
postal_code: "80331"
},
%{
first_name: "Greta",
last_name: "Schmidt",
email: "greta.schmidt@example.de",
birth_date: ~D[1990-03-22],
join_date: ~D[2023-02-01],
paid: false,
phone_number: "+49309876543",
city: "Hamburg",
street: "Lindenstraße",
house_number: "17",
postal_code: "20095",
notes: "Interessiert an Fortgeschrittenen-Kursen"
},
%{
first_name: "Friedrich",
last_name: "Wagner",
email: "friedrich.wagner@example.de",
birth_date: ~D[1978-11-08],
join_date: ~D[2022-11-10],
paid: true,
phone_number: "+49301122334",
city: "Berlin",
street: "Kastanienallee",
house_number: "8"
},
%{
first_name: "Marianne",
last_name: "Wagner",
email: "marianne.wagner@example.de",
birth_date: ~D[1978-11-08],
join_date: ~D[2022-11-10],
paid: true,
phone_number: "+49301122334",
city: "Berlin",
street: "Kastanienallee",
house_number: "8"
}
] do
# Use upsert to prevent duplicates based on email
Membership.create_member!(member_attrs, upsert?: true, upsert_identity: :unique_email)
end
# Create additional users for user-member linking examples
additional_users = [
%{email: "hans.mueller@example.de"},
%{email: "greta.schmidt@example.de"},
%{email: "maria.weber@example.de"},
%{email: "thomas.klein@example.de"}
]
created_users =
Enum.map(additional_users, fn user_attrs ->
Accounts.create_user!(user_attrs, upsert?: true, upsert_identity: :unique_email)
|> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"})
|> Ash.update!()
end)
# Create members with linked users to demonstrate the 1:1 relationship
# Only create if users don't already have members
linked_members = [
%{
first_name: "Maria",
last_name: "Weber",
email: "maria.weber@example.de",
birth_date: ~D[1992-07-14],
join_date: ~D[2023-03-15],
paid: true,
phone_number: "+49301357924",
city: "Frankfurt",
street: "Goetheplatz",
house_number: "5",
postal_code: "60313",
notes: "Linked to user account",
# Link to the third user (maria.weber@example.de)
user: Enum.at(created_users, 2)
},
%{
first_name: "Thomas",
last_name: "Klein",
email: "thomas.klein@example.de",
birth_date: ~D[1988-12-03],
join_date: ~D[2023-04-01],
paid: false,
phone_number: "+49302468135",
city: "Köln",
street: "Rheinstraße",
house_number: "23",
postal_code: "50667",
notes: "Linked to user account - needs payment follow-up",
# Link to the fourth user (thomas.klein@example.de)
user: Enum.at(created_users, 3)
}
]
# Create the linked members - use upsert to prevent duplicates
Enum.each(linked_members, fn member_attrs ->
user = member_attrs.user
member_attrs_without_user = Map.delete(member_attrs, :user)
# Check if user already has a member
if user.member_id == nil do
# User is free, create member and link - use upsert to prevent duplicates
Membership.create_member!(
Map.put(member_attrs_without_user, :user, %{id: user.id}),
upsert?: true,
upsert_identity: :unique_email
)
else
# User already has a member, just create the member without linking - use upsert to prevent duplicates
Membership.create_member!(member_attrs_without_user,
upsert?: true,
upsert_identity: :unique_email
)
end
end)
IO.puts("✅ Seeds completed successfully!")
IO.puts("📝 Created sample data:")
IO.puts(" - Property types: String, Date, Boolean, Email")
IO.puts(" - Admin user: admin@mv.local (password: testpassword)")
IO.puts(" - Sample members: Hans, Greta, Friedrich")
IO.puts(
" - Additional users: hans.mueller@example.de, greta.schmidt@example.de, maria.weber@example.de, thomas.klein@example.de"
)
IO.puts(
" - Linked members: Maria Weber ↔ maria.weber@example.de, Thomas Klein ↔ thomas.klein@example.de"
)
IO.puts("🔗 Visit the application to see user-member relationships in action!")

View file

@ -0,0 +1,199 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v7()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "first_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "last_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "email",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "birth_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "paid",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "phone_number",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "join_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "exit_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "notes",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "city",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "street",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "house_number",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "postal_code",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "search_vectors",
"type": "tsvector"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "3B162FD69B92BF8258DB56BA0CBB6108FBE996B1F7231C5F2D9EC53D956EFC75",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "members"
}

View file

@ -0,0 +1,202 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v7()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "first_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "last_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "email",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "birth_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "paid",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "phone_number",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "join_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "exit_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "notes",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "city",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "street",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "house_number",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "postal_code",
"type": "text"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "5F070A1E5BEE9883AE864FB5A4A5E81F487A1C57D41576C23BAC8D933005D565",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "members_unique_email_index",
"keys": [
{
"type": "atom",
"value": "email"
}
],
"name": "unique_email",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "members"
}

View file

@ -0,0 +1,214 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v7()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "first_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "last_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "email",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "birth_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "paid",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "phone_number",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "join_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "exit_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "notes",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "city",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "street",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "house_number",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "postal_code",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "search_vector",
"type": "tsvector"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "6AAC71BCCA5F112087CEF6877A5BBF74EF8965D5DA4812C44CD6E672F882CC3F",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "members_unique_email_index",
"keys": [
{
"type": "atom",
"value": "email"
}
],
"name": "unique_email",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "members"
}

View file

@ -0,0 +1,124 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "value",
"type": "map"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "properties_member_id_fkey",
"on_delete": "delete",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "members"
},
"scale": null,
"size": null,
"source": "member_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "properties_property_type_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "property_types"
},
"scale": null,
"size": null,
"source": "property_type_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "8F90B1AAD1063CF2BB0BDEBBDFBA86AF0B24D854689FB834BC20DFAB2143A451",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "properties_unique_property_per_member_index",
"keys": [
{
"type": "atom",
"value": "member_id"
},
{
"type": "atom",
"value": "property_type_id"
}
],
"name": "unique_property_per_member",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "properties"
}

View file

@ -0,0 +1,141 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "email",
"type": "citext"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "hashed_password",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "oidc_id",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "users_member_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "members"
},
"scale": null,
"size": null,
"source": "member_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "FDEBD4840449609DDA8B50D6741C2EEDE9D81DFBC1E26D4BC77DBD9B5A8EA4DC",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_email_index",
"keys": [
{
"type": "atom",
"value": "email"
}
],
"name": "unique_email",
"nils_distinct?": true,
"where": null
},
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_oidc_id_index",
"keys": [
{
"type": "atom",
"value": "oidc_id"
}
],
"name": "unique_oidc_id",
"nils_distinct?": true,
"where": null
},
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_member_index",
"keys": [
{
"type": "atom",
"value": "member_id"
}
],
"name": "unique_member",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "users"
}

View file

@ -0,0 +1,141 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "email",
"type": "citext"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "hashed_password",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "oidc_id",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "users_member_id_fkey",
"on_delete": "nilify",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "members"
},
"scale": null,
"size": null,
"source": "member_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "1D22936DF847949B543000F3E2E4BDA7D78682AAE6EE0CB9CBD55A4F8F4A7228",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_email_index",
"keys": [
{
"type": "atom",
"value": "email"
}
],
"name": "unique_email",
"nils_distinct?": true,
"where": null
},
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_member_index",
"keys": [
{
"type": "atom",
"value": "member_id"
}
],
"name": "unique_member",
"nils_distinct?": true,
"where": null
},
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_oidc_id_index",
"keys": [
{
"type": "atom",
"value": "oidc_id"
}
],
"name": "unique_oidc_id",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "users"
}

View file

@ -1,7 +1,7 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"schedule": ["* * 3 * *"],
"schedule": ["* * 1-7 * *"],
"packageRules": [
{
"groupName": "Mix dependencies",

View file

@ -0,0 +1,93 @@
defmodule Mv.Accounts.EmailSyncEdgeCasesTest do
@moduledoc """
Edge case tests for email synchronization between User and Member.
Tests various boundary conditions and validation scenarios.
"""
use Mv.DataCase, async: false
alias Mv.Accounts
alias Mv.Membership
describe "Email sync edge cases" do
@valid_user_attrs %{
email: "user@example.com"
}
@valid_member_attrs %{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
}
test "simultaneous email updates use user email as source of truth" do
# Create linked user and member
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
# Verify link and initial sync
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
assert synced_member.email == "user@example.com"
# Scenario: Both emails are updated "simultaneously"
# In practice, this tests that when a member email is updated,
# it syncs to user, and user remains the source of truth
# Update member email first
{:ok, _updated_member} =
Membership.update_member(member, %{email: "member-new@example.com"})
# Verify it synced to user
{:ok, user_after_member_update} = Ash.get(Mv.Accounts.User, user.id)
assert to_string(user_after_member_update.email) == "member-new@example.com"
# Now update user email - this should override
{:ok, _updated_user} =
Accounts.update_user(user_after_member_update, %{email: "user-final@example.com"})
# Reload both
{:ok, final_user} = Ash.get(Mv.Accounts.User, user.id)
{:ok, final_member} = Ash.get(Mv.Membership.Member, member.id)
# User email should be the final truth
assert to_string(final_user.email) == "user-final@example.com"
assert final_member.email == "user-final@example.com"
end
test "email validation works for both user and member" do
# Test that invalid emails are rejected for both resources
# Invalid email for user
invalid_user_result = Accounts.create_user(%{email: "not-an-email"})
assert {:error, %Ash.Error.Invalid{}} = invalid_user_result
# Invalid email for member
invalid_member_attrs = Map.put(@valid_member_attrs, :email, "also-not-an-email")
invalid_member_result = Membership.create_member(invalid_member_attrs)
assert {:error, %Ash.Error.Invalid{}} = invalid_member_result
# Valid emails should work
{:ok, _user} = Accounts.create_user(@valid_user_attrs)
{:ok, _member} = Membership.create_member(@valid_member_attrs)
end
test "identity constraints prevent duplicate emails" do
# Create first user with an email
{:ok, user1} = Accounts.create_user(%{email: "duplicate@example.com"})
assert to_string(user1.email) == "duplicate@example.com"
# Try to create second user with same email - should fail due to unique constraint
result = Accounts.create_user(%{email: "duplicate@example.com"})
assert {:error, %Ash.Error.Invalid{}} = result
# Same for members
member_attrs = Map.put(@valid_member_attrs, :email, "member-dup@example.com")
{:ok, member1} = Membership.create_member(member_attrs)
assert member1.email == "member-dup@example.com"
# Try to create second member with same email - should fail
result2 = Membership.create_member(member_attrs)
assert {:error, %Ash.Error.Invalid{}} = result2
end
end
end

View file

@ -0,0 +1,480 @@
defmodule Mv.Accounts.EmailUniquenessTest do
use Mv.DataCase, async: false
alias Mv.Accounts
alias Mv.Membership
describe "Email uniqueness validation - Creation" do
test "CAN create member with existing unlinked user email" do
# Create a user with email
{:ok, _user} =
Accounts.create_user(%{
email: "existing@example.com"
})
# Create member with same email - should succeed
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "existing@example.com"
})
assert to_string(member.email) == "existing@example.com"
end
test "CAN create user with existing unlinked member email" do
# Create a member with email
{:ok, _member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "existing@example.com"
})
# Create user with same email - should succeed
{:ok, user} =
Accounts.create_user(%{
email: "existing@example.com"
})
assert to_string(user.email) == "existing@example.com"
end
end
describe "Email uniqueness validation - Updating unlinked entities" do
test "unlinked member email CAN be changed to an existing unlinked user email" do
# Create a user with email
{:ok, _user} =
Accounts.create_user(%{
email: "existing_user@example.com"
})
# Create an unlinked member with different email
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
})
# Change member email to existing user email - should succeed (member is unlinked)
{:ok, updated_member} =
Membership.update_member(member, %{
email: "existing_user@example.com"
})
assert to_string(updated_member.email) == "existing_user@example.com"
end
test "unlinked user email CAN be changed to an existing unlinked member email" do
# Create a member with email
{:ok, _member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "existing_member@example.com"
})
# Create an unlinked user with different email
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
# Change user email to existing member email - should succeed (user is unlinked)
{:ok, updated_user} =
Accounts.update_user(user, %{
email: "existing_member@example.com"
})
assert to_string(updated_user.email) == "existing_member@example.com"
end
test "unlinked member email CANNOT be changed to an existing linked user email" do
# Create a user and link it to a member - this makes the user "linked"
{:ok, user} =
Accounts.create_user(%{
email: "linked_user@example.com"
})
{:ok, _member_a} =
Membership.create_member(%{
first_name: "Member",
last_name: "A",
email: "temp@example.com",
user: %{id: user.id}
})
# Create an unlinked member with different email
{:ok, member_b} =
Membership.create_member(%{
first_name: "Member",
last_name: "B",
email: "member_b@example.com"
})
# Try to change unlinked member's email to linked user's email - should fail
result =
Membership.update_member(member_b, %{
email: "linked_user@example.com"
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
test "unlinked user email CANNOT be changed to an existing linked member email" do
# Create a user and link it to a member - this makes the member "linked"
{:ok, user_a} =
Accounts.create_user(%{
email: "user_a@example.com"
})
{:ok, _member_a} =
Membership.create_member(%{
first_name: "Member",
last_name: "A",
email: "temp@example.com",
user: %{id: user_a.id}
})
# Reload user to get updated member_id and linked member email
{:ok, user_a_reloaded} = Ash.get(Mv.Accounts.User, user_a.id)
{:ok, user_a_with_member} = Ash.load(user_a_reloaded, :member)
linked_member_email = to_string(user_a_with_member.member.email)
# Create an unlinked user with different email
{:ok, user_b} =
Accounts.create_user(%{
email: "user_b@example.com"
})
# Try to change unlinked user's email to linked member's email - should fail
result =
Accounts.update_user(user_b, %{
email: linked_member_email
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
end
describe "Email uniqueness validation - Creating with linked emails" do
test "CANNOT create member with existing linked user email" do
# Create a user and link it to a member
{:ok, user} =
Accounts.create_user(%{
email: "linked@example.com"
})
{:ok, _member} =
Membership.create_member(%{
first_name: "First",
last_name: "Member",
email: "temp@example.com",
user: %{id: user.id}
})
# Try to create a new member with the linked user's email - should fail
result =
Membership.create_member(%{
first_name: "Second",
last_name: "Member",
email: "linked@example.com"
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
test "CANNOT create user with existing linked member email" do
# Create a user and link it to a member
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
{:ok, _member} =
Membership.create_member(%{
first_name: "Member",
last_name: "One",
email: "temp@example.com",
user: %{id: user.id}
})
# Reload user to get the linked member's email
{:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id)
{:ok, user_with_member} = Ash.load(user_reloaded, :member)
linked_member_email = to_string(user_with_member.member.email)
# Try to create a new user with the linked member's email - should fail
result =
Accounts.create_user(%{
email: linked_member_email
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
end
describe "Email uniqueness validation - Updating linked entities" do
test "linked member email CANNOT be changed to an existing user email" do
# Create a user with email
{:ok, _other_user} =
Accounts.create_user(%{
email: "other_user@example.com"
})
# Create a user and link it to a member
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "temp@example.com",
user: %{id: user.id}
})
# Try to change linked member's email to other user's email - should fail
result =
Membership.update_member(member, %{
email: "other_user@example.com"
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
test "linked user email CANNOT be changed to an existing member email" do
# Create a member with email
{:ok, _other_member} =
Membership.create_member(%{
first_name: "Jane",
last_name: "Doe",
email: "other_member@example.com"
})
# Create a user and link it to a member
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
{:ok, _member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "temp@example.com",
user: %{id: user.id}
})
# Reload user to get updated member_id
{:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id)
# Try to change linked user's email to other member's email - should fail
result =
Accounts.update_user(user_reloaded, %{
email: "other_member@example.com"
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
end
describe "Email uniqueness validation - Linking" do
test "CANNOT link user to member if user email is already used by another unlinked member" do
# Create a member with email
{:ok, _other_member} =
Membership.create_member(%{
first_name: "Jane",
last_name: "Doe",
email: "duplicate@example.com"
})
# Create a user with same email
{:ok, user} =
Accounts.create_user(%{
email: "duplicate@example.com"
})
# Create a member to link with the user
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Smith",
email: "john@example.com"
})
# Try to link user to member - should fail because user.email is already used by other_member
result =
Accounts.update_user(user, %{
member: %{id: member.id}
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
test "CAN link member to user even if member email is used by another user (member email gets overridden)" do
# Create a user with email
{:ok, _other_user} =
Accounts.create_user(%{
email: "duplicate@example.com"
})
# Create a member with same email
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "duplicate@example.com"
})
# Create a user to link with the member
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
# Link member to user - should succeed because member.email will be overridden
{:ok, updated_member} =
Membership.update_member(member, %{
user: %{id: user.id}
})
# Member email should now be the same as user email
{:ok, member_reloaded} = Ash.get(Mv.Membership.Member, updated_member.id)
assert to_string(member_reloaded.email) == "user@example.com"
end
end
describe "Email syncing" do
test "member email syncs to linked user email without validation error" do
# Create a user
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
# Create a member linked to this user
# The override change will set member.email = user.email automatically
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "member@example.com",
user: %{id: user.id}
})
# Member email should have been overridden to user email
# This happens through our sync mechanism, which should NOT trigger
# the "email already used" validation because it's the same user
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id)
assert member_after_link.email == "user@example.com"
end
test "user email syncs to linked member without validation error" do
# Create a member
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
})
# Create a user linked to this member
# The override change will set member.email = user.email automatically
{:ok, _user} =
Accounts.create_user(%{
email: "user@example.com",
member: %{id: member.id}
})
# Member email should have been overridden to user email
# This happens through our sync mechanism, which should NOT trigger
# the "email already used" validation because it's the same member
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id)
assert member_after_link.email == "user@example.com"
end
test "two unlinked users cannot have the same email" do
# Create first user
{:ok, _user1} =
Accounts.create_user(%{
email: "duplicate@example.com"
})
# Try to create second user with same email
result =
Accounts.create_user(%{
email: "duplicate@example.com"
})
assert {:error, %Ash.Error.Invalid{}} = result
end
test "two unlinked members cannot have the same email (members have unique constraint)" do
# Create first member
{:ok, _member1} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "duplicate@example.com"
})
# Try to create second member with same email - should fail
result =
Membership.create_member(%{
first_name: "Jane",
last_name: "Smith",
email: "duplicate@example.com"
})
assert {:error, %Ash.Error.Invalid{}} = result
# Members DO have a unique email constraint at database level
end
end
end

View file

@ -0,0 +1,169 @@
defmodule Mv.Accounts.UserEmailSyncTest do
@moduledoc """
Tests for email synchronization from User to Member.
When a user and member are linked, email changes should sync bidirectionally.
User.email is the source of truth when linking occurs.
"""
use Mv.DataCase, async: false
alias Mv.Accounts
alias Mv.Membership
describe "User email synchronization to linked Member" do
@valid_user_attrs %{
email: "user@example.com"
}
@valid_member_attrs %{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
}
test "updating user email syncs to linked member" do
# Create a member
{:ok, member} = Membership.create_member(@valid_member_attrs)
assert member.email == "member@example.com"
# Create a user linked to the member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
# Verify initial state - member email should be overridden by user email
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id)
assert member_after_link.email == "user@example.com"
# Update user email
{:ok, updated_user} = Accounts.update_user(user, %{email: "newemail@example.com"})
assert to_string(updated_user.email) == "newemail@example.com"
# Verify member email was also updated
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
assert synced_member.email == "newemail@example.com"
end
test "creating user linked to member overrides member email" do
# Create a member with their own email
{:ok, member} = Membership.create_member(@valid_member_attrs)
assert member.email == "member@example.com"
# Create a user linked to this member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
assert to_string(user.email) == "user@example.com"
assert user.member_id == member.id
# Verify member email was overridden with user email
{:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id)
assert updated_member.email == "user@example.com"
end
test "linking user to existing member syncs user email to member" do
# Create a standalone member
{:ok, member} = Membership.create_member(@valid_member_attrs)
assert member.email == "member@example.com"
# Create a standalone user
{:ok, user} = Accounts.create_user(@valid_user_attrs)
assert to_string(user.email) == "user@example.com"
assert user.member_id == nil
# Link the user to the member
{:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member.id}})
assert linked_user.member_id == member.id
# Verify member email was overridden with user email
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
assert synced_member.email == "user@example.com"
end
test "updating user email when no member linked does not error" do
# Create a standalone user without member link
{:ok, user} = Accounts.create_user(@valid_user_attrs)
assert to_string(user.email) == "user@example.com"
assert user.member_id == nil
# Update user email - should work fine without error
{:ok, updated_user} = Accounts.update_user(user, %{email: "newemail@example.com"})
assert to_string(updated_user.email) == "newemail@example.com"
assert updated_user.member_id == nil
end
test "unlinking user from member does not sync email" do
# Create member
{:ok, member} = Membership.create_member(@valid_member_attrs)
# Create user linked to member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
assert user.member_id == member.id
# Verify member email was synced
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
assert synced_member.email == "user@example.com"
# Unlink user from member
{:ok, unlinked_user} = Accounts.update_user(user, %{member: nil})
assert unlinked_user.member_id == nil
# Member email should remain unchanged after unlinking
{:ok, member_after_unlink} = Ash.get(Mv.Membership.Member, member.id)
assert member_after_unlink.email == "user@example.com"
end
end
describe "AshAuthentication compatibility" do
test "AshAuthentication password strategy still works with email" do
# This test ensures that the email field remains accessible for password auth
email = "test@example.com"
password = "securepassword123"
# Create user with password strategy (simulating registration)
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: email,
password: password
})
|> Ash.create()
assert to_string(user.email) == email
assert user.hashed_password != nil
# Verify we can sign in with email
{:ok, signed_in_user} =
Mv.Accounts.User
|> Ash.Query.for_read(:sign_in_with_password, %{
email: email,
password: password
})
|> Ash.read_one()
assert signed_in_user.id == user.id
assert to_string(signed_in_user.email) == email
end
test "AshAuthentication OIDC strategy still works with email" do
# This test ensures the OIDC flow can still set email
user_info = %{
"preferred_username" => "oidc@example.com",
"sub" => "oidc-user-123"
}
oauth_tokens = %{"access_token" => "mock_token"}
# Simulate OIDC registration
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_rauthy, %{
user_info: user_info,
oauth_tokens: oauth_tokens
})
|> Ash.create()
assert to_string(user.email) == "oidc@example.com"
assert user.oidc_id == "oidc-user-123"
end
end
end

View file

@ -0,0 +1,88 @@
defmodule Mv.Accounts.UserMemberDeletionTest do
@moduledoc """
Tests for ON DELETE SET NULL constraint on users.member_id.
When a member is deleted, the linked user should remain but with member_id set to NULL.
"""
use Mv.DataCase, async: true
alias Mv.Accounts
alias Mv.Membership
describe "User remains when linked Member is deleted (ON DELETE SET NULL)" do
@valid_user_attrs %{
email: "test@example.com"
}
@valid_member_attrs %{
first_name: "John",
last_name: "Doe",
email: "john@example.com"
}
test "deleting a member sets the user's member_id to NULL" do
# Create a member
{:ok, member} = Membership.create_member(@valid_member_attrs)
# Create a user linked to the member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
# Verify the relationship is established
{:ok, user_before_delete} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
assert user_before_delete.member_id == member.id
assert user_before_delete.member.id == member.id
# Delete the member
:ok = Membership.destroy_member(member)
# Verify the user still exists but member_id is NULL
{:ok, user_after_delete} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
assert user_after_delete.id == user.id
assert user_after_delete.member_id == nil
assert user_after_delete.member == nil
end
test "user can be linked to a new member after old member is deleted" do
# Create first member
{:ok, member1} = Membership.create_member(@valid_member_attrs)
# Create user linked to first member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member1.id}))
assert user.member_id == member1.id
# Delete first member
:ok = Membership.destroy_member(member1)
# Reload user from database to get updated member_id (should be NULL)
{:ok, user_after_delete} = Ash.get(Mv.Accounts.User, user.id)
assert user_after_delete.member_id == nil
# Create second member
{:ok, member2} =
Membership.create_member(%{
first_name: "Jane",
last_name: "Smith",
email: "jane@example.com"
})
# Link user to second member (use reloaded user)
{:ok, updated_user} = Accounts.update_user(user_after_delete, %{member: %{id: member2.id}})
# Verify new relationship
{:ok, final_user} = Ash.get(Mv.Accounts.User, updated_user.id, load: [:member])
assert final_user.member_id == member2.id
assert final_user.member.id == member2.id
end
test "member without linked user can be deleted normally" do
{:ok, member} = Membership.create_member(@valid_member_attrs)
# Delete member (no users linked)
assert :ok = Membership.destroy_member(member)
# Verify member is deleted
assert {:error, _} = Ash.get(Mv.Membership.Member, member.id)
end
end
end

View file

@ -0,0 +1,197 @@
defmodule Mv.Accounts.UserMemberRelationshipTest do
# Using async: true for faster test execution
# This is safe because all database operations are sandboxed per test
use Mv.DataCase, async: true
alias Mv.Accounts
alias Mv.Membership
describe "User-Member Relationship - Basic Tests" do
@valid_user_attrs %{
email: "test@example.com"
}
@valid_member_attrs %{
first_name: "John",
last_name: "Doe",
email: "john@example.com"
}
test "user can exist without member" do
{:ok, user} = Accounts.create_user(@valid_user_attrs)
assert user.member_id == nil
# Load the relationship to test it
{:ok, user_with_member} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
assert user_with_member.member == nil
end
test "member can exist without user" do
{:ok, member} = Membership.create_member(@valid_member_attrs)
assert member.id != nil
assert member.first_name == "John"
# Load the relationship to test it
{:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
assert member_with_user.user == nil
end
end
describe "User-Member Relationship - Linking Tests" do
@valid_user_attrs %{
email: "test1@example.com"
}
@valid_member_attrs %{
first_name: "Alice",
last_name: "Johnson",
email: "alice@example.com"
}
test "user can be linked to member during user creation" do
{:ok, member} = Membership.create_member(@valid_member_attrs)
user_attrs = Map.put(@valid_user_attrs, :member, %{id: member.id})
{:ok, user} = Accounts.create_user(user_attrs)
# Load the relationship to test it
{:ok, user_with_member} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
assert user_with_member.member.id == member.id
end
test "member can be linked to user during member creation using manage_relationship" do
{:ok, user} = Accounts.create_user(@valid_user_attrs)
member_attrs = Map.put(@valid_member_attrs, :user, %{id: user.id})
{:ok, member} = Membership.create_member(member_attrs)
# Load the relationship to test it
{:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
assert member_with_user.user.id == user.id
end
test "user can be linked to member during update" do
{:ok, user} = Accounts.create_user(@valid_user_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, updated_user} = Accounts.update_user(user, %{member: %{id: member.id}})
# Load the relationship to test it
{:ok, user_with_member} = Ash.get(Mv.Accounts.User, updated_user.id, load: [:member])
assert user_with_member.member.id == member.id
end
test "member can be linked to user during update using manage_relationship" do
{:ok, user} = Accounts.create_user(@valid_user_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, _updated_member} = Membership.update_member(member, %{user: %{id: user.id}})
# Load the relationship to test it
{:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
assert member_with_user.user.id == user.id
end
end
describe "User-Member Relationship - Inverse Relationship Tests" do
@valid_user_attrs %{
email: "test2@example.com"
}
@valid_member_attrs %{
first_name: "Bob",
last_name: "Smith",
email: "bob@example.com"
}
test "ash resolves inverse relationship automatically" do
{:ok, member} = Membership.create_member(@valid_member_attrs)
user_attrs = Map.put(@valid_user_attrs, :member, %{id: member.id})
{:ok, user} = Accounts.create_user(user_attrs)
# Load relationships
{:ok, user_with_member} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
{:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
assert user_with_member.member.id == member.id
assert member_with_user.user.id == user.id
end
test "member can find associated user" do
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, user} = Accounts.create_user(%{email: "test3@example.com", member: %{id: member.id}})
{:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
assert member_with_user.user.id == user.id
end
end
describe "User-Member Relationship - Preventing Duplicates" do
@valid_user_attrs %{
email: "test4@example.com"
}
@valid_member_attrs %{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
}
test "prevents overwriting a member of already linked user on update" do
{:ok, existing_member} = Membership.create_member(@valid_member_attrs)
user_attrs = Map.put(@valid_user_attrs, :member, %{id: existing_member.id})
{:ok, user} = Accounts.create_user(user_attrs)
{:ok, member2} =
Membership.create_member(%{
first_name: "Dave",
last_name: "Wilson",
email: "dave@example.com"
})
assert {:error, %Ash.Error.Invalid{}} =
Accounts.update_user(user, %{member: %{id: member2.id}})
end
test "prevents linking user to already linked member on update" do
{:ok, existing_user} = Accounts.create_user(@valid_user_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, _updated_user} = Accounts.update_user(existing_user, %{member: %{id: member.id}})
{:ok, user2} = Accounts.create_user(%{email: "test5@example.com"})
assert {:error, %Ash.Error.Invalid{}} =
Accounts.update_user(user2, %{member: %{id: member.id}})
end
test "prevents linking member to already linked user on creation" do
{:ok, existing_member} = Membership.create_member(@valid_member_attrs)
user_attrs = Map.put(@valid_user_attrs, :member, %{id: existing_member.id})
{:ok, user} = Accounts.create_user(user_attrs)
assert {:error, %Ash.Error.Invalid{}} =
Membership.create_member(%{
first_name: "Dave",
last_name: "Wilson",
email: "dave@example.com",
user: %{id: user.id}
})
end
test "prevents linking user to already linked member on creation" do
{:ok, existing_user} = Accounts.create_user(@valid_user_attrs)
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, _updated_user} = Accounts.update_user(existing_user, %{member: %{id: member.id}})
assert {:error, %Ash.Error.Invalid{}} =
Accounts.create_user(%{
email: "test5@example.com",
member: %{id: member.id}
})
end
end
end

View file

@ -0,0 +1,127 @@
defmodule Mv.Membership.MemberEmailSyncTest do
@moduledoc """
Tests for email synchronization from Member to User.
When a member and user are linked, email changes should sync bidirectionally.
User.email is the source of truth when linking occurs.
"""
use Mv.DataCase, async: false
alias Mv.Accounts
alias Mv.Membership
describe "Member email synchronization to linked User" do
@valid_user_attrs %{
email: "user@example.com"
}
@valid_member_attrs %{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
}
test "updating member email syncs to linked user" do
# Create a user
{:ok, user} = Accounts.create_user(@valid_user_attrs)
assert to_string(user.email) == "user@example.com"
# Create a member linked to the user
{:ok, member} =
Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id}))
# Verify initial state - member email should be overridden by user email
{:ok, member_after_create} = Ash.get(Mv.Membership.Member, member.id)
assert member_after_create.email == "user@example.com"
# Update member email
{:ok, updated_member} =
Membership.update_member(member, %{email: "newmember@example.com"})
assert updated_member.email == "newmember@example.com"
# Verify user email was also updated
{:ok, synced_user} = Ash.get(Mv.Accounts.User, user.id)
assert to_string(synced_user.email) == "newmember@example.com"
end
test "creating member linked to user syncs user email to member" do
# Create a user with their own email
{:ok, user} = Accounts.create_user(@valid_user_attrs)
assert to_string(user.email) == "user@example.com"
# Create a member linked to this user
{:ok, member} =
Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id}))
# Member should have been created with user's email (user is source of truth)
assert member.email == "user@example.com"
# Verify the link
{:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
assert loaded_member.user.id == user.id
end
test "linking member to existing user syncs user email to member" do
# Create a standalone user
{:ok, user} = Accounts.create_user(@valid_user_attrs)
assert to_string(user.email) == "user@example.com"
# Create a standalone member
{:ok, member} = Membership.create_member(@valid_member_attrs)
assert member.email == "member@example.com"
# Link the member to the user
{:ok, linked_member} = Membership.update_member(member, %{user: %{id: user.id}})
# Verify the link
{:ok, loaded_member} = Ash.get(Mv.Membership.Member, linked_member.id, load: [:user])
assert loaded_member.user.id == user.id
# Verify member email was overridden with user email
assert loaded_member.email == "user@example.com"
end
test "updating member email when no user linked does not error" do
# Create a standalone member without user link
{:ok, member} = Membership.create_member(@valid_member_attrs)
assert member.email == "member@example.com"
# Load to verify no user link
{:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
assert loaded_member.user == nil
# Update member email - should work fine without error
{:ok, updated_member} =
Membership.update_member(member, %{email: "newemail@example.com"})
assert updated_member.email == "newemail@example.com"
end
test "unlinking member from user does not sync email" do
# Create user
{:ok, user} = Accounts.create_user(@valid_user_attrs)
# Create member linked to user
{:ok, member} =
Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id}))
# Verify member email was synced to user email
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
assert synced_member.email == "user@example.com"
# Verify link exists
{:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
assert loaded_member.user != nil
# Unlink member from user
{:ok, unlinked_member} = Membership.update_member(member, %{user: nil})
# Verify unlink
{:ok, loaded_unlinked} = Ash.get(Mv.Membership.Member, unlinked_member.id, load: [:user])
assert loaded_unlinked.user == nil
# User email should remain unchanged after unlinking
{:ok, user_after_unlink} = Ash.get(Mv.Accounts.User, user.id)
assert to_string(user_after_unlink.email) == "user@example.com"
end
end
end

View file

@ -0,0 +1,88 @@
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
end
end

View file

@ -0,0 +1,33 @@
defmodule MvWeb.Components.SearchBarComponentTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
describe "SearchBarComponent" do
test "renders with placeholder", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
assert has_element?(view, "input[placeholder='Search...']")
end
test "updates query when user types", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# simulate search input and check that other members are not listed
html =
view
|> element("form[role=search]")
|> render_submit(%{"query" => "Friedrich"})
refute html =~ "Greta"
html =
view
|> element("form[role=search]")
|> render_submit(%{"query" => "Greta"})
refute html =~ "Friedrich"
end
end
end

View file

@ -0,0 +1,319 @@
defmodule MvWeb.Components.SortHeaderComponentTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
describe "rendering" do
test "renders with correct attributes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Test that the component renders with correct attributes
assert has_element?(view, "[data-testid='first_name']")
assert has_element?(view, "button[phx-value-field='city']")
assert has_element?(view, "button[phx-value-field='first_name']", "First name")
end
test "renders all sortable headers", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
sortable_fields = [
:first_name,
:email,
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
]
for field <- sortable_fields do
assert has_element?(view, "button[phx-value-field='#{field}']")
end
end
test "renders correct labels", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Test specific labels
assert has_element?(view, "button[phx-value-field='first_name']", "First name")
assert has_element?(view, "button[phx-value-field='email']", "Email")
assert has_element?(view, "button[phx-value-field='city']", "City")
end
end
describe "sort icons" do
test "shows neutral icon for specific field when not sorted", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# The neutral icon has the opcity class we can test for
# Test that EMAIL field specifically shows neutral icon
assert has_element?(view, "[data-testid='email'] .opacity-40")
# Test that CITY field specifically shows neutral icon
assert has_element?(view, "[data-testid='city'] .opacity-40")
end
test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?query=&sort_field=city&sort_order=asc")
# Test that FIRST_NAME field specifically shows ascending icon
# Test CSS classes - no opacity for active state
refute has_element?(view, "[data-testid='city'] .opacity-40")
# Test that OTHER fields still show neutral icons
assert has_element?(view, "[data-testid='first_name'] .opacity-40")
# Test HTML content - should contain chevronup AND chevron up down
assert html =~ "hero-chevron-up"
assert html =~ "hero-chevron-up-down"
# Count occurrences to ensure only one ascending icon
up_count = html |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
# Should be exactly one chevronup icon
assert up_count == 1
end
test "shows descending icon for specific field when sorted descending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
# Count occurrences to ensure only one descending icon
down_count = html |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
# Should be exactly one chevrondown icon
assert down_count == 1
end
test "multiple fields can have different icon states", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=city&sort_order=asc")
# CITY field should be active (ascending)
refute has_element?(view, "[data-testid='city'] .opacity-40")
# All other fields should be neutral
assert has_element?(view, "[data-testid='first_name'] .opacity-40")
assert has_element?(view, "[data-testid='email'] .opacity-40")
assert has_element?(view, "[data-testid='street'] .opacity-40")
assert has_element?(view, "[data-testid='house_number'] .opacity-40")
assert has_element?(view, "[data-testid='postal_code'] .opacity-40")
assert has_element?(view, "[data-testid='phone_number'] .opacity-40")
assert has_element?(view, "[data-testid='join_date'] .opacity-40")
end
test "icon state changes correctly when clicking different fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Start: all fields neutral except first name as default
assert has_element?(view, "[data-testid='city'] .opacity-40")
refute has_element?(view, "[data-testid='first_name'] .opacity-40")
assert has_element?(view, "[data-testid='email'] .opacity-40")
# Click city - should become active
view
|> element("button[phx-value-field='city']")
|> render_click()
# city should be active, email should still be neutral
refute has_element?(view, "[data-testid='city'] .opacity-40")
assert has_element?(view, "[data-testid='email'] .opacity-40")
# Click email - should switch active field
view
|> element("button[phx-value-field='email']")
|> render_click()
# email should be active, city should be neutral again
refute has_element?(view, "[data-testid='email'] .opacity-40")
assert has_element?(view, "[data-testid='city'] .opacity-40")
end
test "specific field shows correct icon for each sort state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Test EMAIL field specifically
{:ok, view, html_asc} = live(conn, "/members?sort_field=email&sort_order=asc")
assert html_asc =~ "hero-chevron-up"
refute has_element?(view, "[data-testid='email'] .opacity-40")
{:ok, view, html_desc} = live(conn, "/members?sort_field=email&sort_order=desc")
assert html_desc =~ "hero-chevron-down"
refute has_element?(view, "[data-testid='email'] .opacity-40")
{:ok, view, html_neutral} = live(conn, "/members")
assert html_neutral =~ "hero-chevron-up-down"
assert has_element?(view, "[data-testid='email'] .opacity-40")
end
test "icon distribution is correct for all fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Test neutral state - all fields except first name (default) should show neutral icons
{:ok, _view, html_neutral} = live(conn, "/members")
# Count neutral icons (should be 7 - one for each field)
neutral_count =
html_neutral |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1)
assert neutral_count == 7
# Count active icons (should be 1)
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
down_count = html_neutral |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
assert up_count == 1
assert down_count == 0
# Test ascending state - one field active, others neutral
{:ok, _view, html_asc} = live(conn, "/members?sort_field=first_name&sort_order=asc")
# Should have exactly 1 ascending icon and 7 neutral icons
up_count = html_asc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
neutral_count = html_asc |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1)
down_count = html_asc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
assert up_count == 1
assert neutral_count == 7
assert down_count == 0
end
end
describe "accessibility" do
test "sets aria-label correctly for unsorted state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Check aria-label for unsorted state
assert has_element?(view, "button[phx-value-field='city'][aria-label='Click to sort']")
end
test "sets aria-label correctly for ascending sort", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc")
# Check aria-label for ascending sort
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='ascending']")
end
test "sets aria-label correctly for descending sort", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=desc")
# Check aria-label for descending sort
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='descending']")
end
test "includes tooltip with correct aria-label", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc")
# Check that tooltip div exists with correct data-tip
assert has_element?(view, "[data-testid='first_name']")
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='ascending']")
end
test "aria-labels work for all sortable fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc")
# Test aria-labels for different fields
assert has_element?(view, "button[phx-value-field='email'][aria-label='descending']")
assert has_element?(
view,
"button[phx-value-field='first_name'][aria-label='Click to sort']"
)
assert has_element?(view, "button[phx-value-field='city'][aria-label='Click to sort']")
end
end
describe "component behavior" do
test "clicking sends sort message to parent", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Click on the first name sort header
view
|> element("button[phx-value-field='first_name']")
|> render_click()
# The component should send a message to the parent LiveView
# This is tested indirectly through the URL change in integration tests
end
test "component handles different field types correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Test that different field types render correctly
assert has_element?(view, "button[phx-value-field='first_name']")
assert has_element?(view, "button[phx-value-field='email']")
assert has_element?(view, "button[phx-value-field='join_date']")
end
end
describe "edge cases" do
test "handles invalid sort field gracefully", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?sort_field=invalid_field&sort_order=asc")
# Should not crash and should default sorting for first name
assert html =~ "hero-chevron-up-down"
refute has_element?(view, "[data-testid='first_name'] .opacity-40")
end
test "handles invalid sort order gracefully", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?sort_field=first_name&sort_order=invalid")
# Should default to ascending
assert html =~ "hero-chevron-up"
refute has_element?(view, "[data-testid='first_name'] [aria-label='ascending']")
end
test "handles empty sort parameters", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?sort_field=&sort_order=")
# Should show neutral icons
assert html =~ "hero-chevron-up-down"
assert has_element?(view, "[data-testid='city'] .opacity-40")
end
end
describe "icon state transitions" do
test "icon changes when sorting state changes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Start with neutral state
assert has_element?(view, "[data-testid='city'] .opacity-40")
# Click to sort ascending
view
|> element("button[phx-value-field='city']")
|> render_click()
# Should now be ascending (no opacity class)
refute has_element?(view, "[data-testid='city'] .opacity-40")
end
test "multiple fields can be tested for icon states", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?sort_field=email&sort_order=desc")
# Email should be active (descending)
assert html =~ "hero-chevron-down"
refute has_element?(view, "[data-testid='email'] .opacity-40")
# Other fields should be neutral
assert has_element?(view, "[data-testid='first_name'] .opacity-40")
assert has_element?(view, "[data-testid='city'] .opacity-40")
end
end
end

View file

@ -0,0 +1,182 @@
defmodule MvWeb.ProfileNavigationTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
describe "profile navigation" do
test "clicking profile button redirects to current user profile", %{conn: conn} do
# Setup: Create and login a user
user = create_test_user(%{email: "test@example.com"})
conn = conn_with_password_user(conn, user)
{:ok, view, _html} = live(conn, "/")
# Click the profile button
view |> element("a", "Profil") |> render_click()
# Verify we're on the profile page
assert_redirected(view, "/users/#{user.id}")
end
test "profile navigation shows correct user data", %{conn: conn} do
# Setup: Create and login a user
user = create_test_user(%{email: "test@example.com"})
conn = conn_with_password_user(conn, user)
# Navigate to profile
{:ok, view, _html} = live(conn, "/")
view |> element("a", "Profil") |> render_click()
# Verify profile data
{:ok, _profile_view, html} = live(conn, "/users/#{user.id}")
assert html =~ to_string(user.email)
assert html =~ "Password Authentication"
assert html =~ "Enabled"
end
end
describe "navbar" do
test "renders profile button with correct attributes", %{conn: conn} do
# Setup: Create and login a user
user = create_test_user(%{email: "test@example.com"})
conn = conn_with_password_user(conn, user)
{:ok, _view, html} = live(conn, "/")
assert html =~ ~s(role="button")
assert html =~ "dropdown-content"
assert html =~ "avatar"
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 and login a user
user = create_test_user(%{email: "test.user@example.com"})
conn = conn_with_password_user(conn, user)
{:ok, _view, html} = live(conn, "/")
# Initials from test.user@example.com
assert html =~ "<span>TU</span>"
end
end
describe "profile navigation with OIDC user" do
test "shows correct profile data for OIDC user", %{conn: conn} do
# Setup: Create OIDC user with sub claim
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)
# Login user via OIDC
conn = sign_in_user_via_oidc(conn, user)
# Navigate to home and click profile
{:ok, view, _html} = live(conn, "/")
view |> element("a", "Profil") |> render_click()
# Verify we're on the correct profile page with OIDC specific information
{:ok, _profile_view, html} = live(conn, "/users/#{user.id}")
assert html =~ to_string(user.email)
# OIDC ID should be visible
assert html =~ "oidc_123"
# Password auth should be disabled for OIDC users
assert html =~ "Not enabled"
end
test "profile navigation works across different authentication methods", %{conn: conn} do
# Create password user
password_user =
create_test_user(%{
email: "password2@example.com",
password: "test_password123"
})
# Create OIDC user
user_info = %{
"sub" => "oidc_789",
"preferred_username" => "oidc@example.com"
}
oauth_tokens = %{
"access_token" => "test_token",
"id_token" => "test_id_token"
}
oidc_user =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_rauthy, %{
user_info: user_info,
oauth_tokens: oauth_tokens
})
|> Ash.create!(domain: Mv.Accounts)
# Test with password user
conn_password = conn_with_password_user(conn, password_user)
{:ok, view_password, _html} = live(conn_password, "/")
view_password |> element("a", "Profil") |> render_click()
assert_redirected(view_password, "/users/#{password_user.id}")
# Test with OIDC user
conn_oidc = sign_in_user_via_oidc(conn, oidc_user)
{:ok, view_oidc, _html} = live(conn_oidc, "/")
view_oidc |> element("a", "Profil") |> render_click()
assert_redirected(view_oidc, "/users/#{oidc_user.id}")
end
end
describe "authenticated views" do
setup %{conn: conn} do
user = create_test_user(%{email: "test@example.com"})
conn = conn_with_password_user(conn, user)
{:ok, conn: conn, user: user}
end
@authenticated_paths [
"/",
"/members",
"/members/new",
"/properties",
"/properties/new",
"/property_types",
"/property_types/new",
"/users",
"/users/new"
]
for path <- @authenticated_paths do
@path path
test "layout shows user data on #{path}", %{conn: conn, user: user} do
{:ok, _view, html} = live(conn, @path)
# The navbar (which requires current_user) should be visible
assert html =~ "navbar"
# Profile button should be visible
assert html =~ "Profil"
# User ID should be in profile link
assert html =~ ~p"/users/#{user.id}"
end
end
test "layout shows user data on user profile page", %{conn: conn, user: user} do
{:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
# The navbar (which requires current_user) should be visible
assert html =~ "navbar"
# Profile button should be visible
assert html =~ "Profil"
# User ID should be in profile link
assert html =~ ~p"/users/#{user.id}"
end
end
end

View file

@ -1,6 +1,7 @@
defmodule MvWeb.MemberLive.IndexTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
require Ash.Query
test "shows translated title in German", %{conn: conn} do
conn = conn_with_oidc_user(conn)
@ -55,7 +56,6 @@ defmodule MvWeb.MemberLive.IndexTest do
test "shows translated flash message after creating a member in English", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = Plug.Test.init_test_session(conn, locale: "en")
{:ok, form_view, _html} = live(conn, "/members/new")
form_data = %{
@ -73,4 +73,180 @@ defmodule MvWeb.MemberLive.IndexTest do
assert has_element?(index_view, "#flash-group", "Member create successfully")
end
describe "sorting integration" do
test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# The component data test ids are built with the name of the field
# First click should sort ASC
view
|> element("[data-testid='email']")
|> render_click()
# The LiveView pushes a patch with the new query params
assert_patch(view, "/members?query=&sort_field=email&sort_order=asc")
# Second click toggles to DESC
view
|> element("[data-testid='email']")
|> render_click()
assert_patch(view, "/members?query=&sort_field=email&sort_order=desc")
end
test "clicking different column header resets order to ascending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc")
# Click on a different column
view
|> element("[data-testid='first_name']")
|> render_click()
assert_patch(view, "/members?query=&sort_field=first_name&sort_order=asc")
end
test "all sortable columns work correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# default ascending sorting with first name
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
sortable_fields = [
:email,
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
]
for field <- sortable_fields do
view
|> element("[data-testid='#{field}']")
|> render_click()
assert_patch(view, "/members?query=&sort_field=#{field}&sort_order=asc")
end
end
test "sorting works with search query", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=test")
view
|> element("[data-testid='email']")
|> render_click()
assert_patch(view, "/members?query=test&sort_field=email&sort_order=asc")
end
test "sorting maintains search query when toggling order", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc")
view
|> element("[data-testid='email']")
|> render_click()
assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc")
end
end
describe "URL param handling" do
test "handle_params reads sort query and applies it", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
# Check that the sort state is correctly applied
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
end
test "handle_params handles invalid sort field gracefully", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=invalid_field&sort_order=asc")
# Should not crash and should show default first name order
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
end
test "handle_params preserves search query with sort params", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=desc")
# Both search and sort should be preserved
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
end
end
describe "search and sort integration" do
test "search maintains sort state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
# Perform search
view
|> element("[data-testid='search-input']")
|> render_change(%{value: "test"})
# Sort state should be maintained
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
end
test "sort maintains search state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc")
# Perform sort
view
|> element("[data-testid='email']")
|> render_click()
# Search state should be maintained
assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc")
end
end
test "handle_info(:search_changed) updates assigns with search results", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
send(view.pid, {:search_changed, "Friedrich"})
state = :sys.get_state(view.pid)
assert state.socket.assigns.query == "Friedrich"
assert is_list(state.socket.assigns.members)
end
test "can delete a member without error", %{conn: conn} do
# Create a test member first
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Test",
last_name: "User",
email: "test@example.com"
})
conn = conn_with_oidc_user(conn)
{:ok, index_view, _html} = live(conn, "/members")
# Verify the member is displayed
assert has_element?(index_view, "#members", "Test User")
# Click the delete link for this member
index_view
|> element("a", "Delete")
|> render_click()
# Verify the member is no longer displayed
refute has_element?(index_view, "#members", "Test User")
# Verify the member was actually deleted from the database
assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?())
end
end

View file

@ -255,7 +255,7 @@ defmodule MvWeb.UserLive.FormTest do
conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, _view, html} = live(conn, "/users/new")
assert html =~ "Neuer Benutzer"
assert html =~ "Neue*r Benutzer*in"
assert html =~ "E-Mail"
assert html =~ "Passwort setzen"
end

View file

@ -7,7 +7,7 @@ defmodule MvWeb.UserLive.IndexTest do
conn = conn_with_oidc_user(conn)
conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, _view, html} = live(conn, "/users")
assert html =~ "Benutzer auflisten"
assert html =~ "Benutzer*innen auflisten"
end
test "shows translated title in English", %{conn: conn} do
@ -288,7 +288,7 @@ defmodule MvWeb.UserLive.IndexTest do
|> element("input[type='checkbox'][name='select_all'][checked]")
|> has_element?()
# Select second user
# Select second user
html = view |> element("input[type='checkbox'][name='#{user2.id}']") |> render_click()
# Now select all should be automatically checked (all individual users are selected)
@ -362,8 +362,8 @@ defmodule MvWeb.UserLive.IndexTest do
conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, _view, html} = live(conn, "/users")
assert html =~ "Alle Benutzer auswählen"
assert html =~ "Benutzer auswählen"
assert html =~ "Alle Benutzer*innen auswählen"
assert html =~ "Benutzer*in auswählen"
end
test "shows English translations for selection", %{conn: conn} do
@ -388,7 +388,8 @@ defmodule MvWeb.UserLive.IndexTest do
assert html =~ "Email"
assert html =~ "OIDC ID"
# Should show the authenticated user at minimum
assert html =~ "user@example.com"
# Matches the generated email pattern oidc.user{unique_id}@example.com
assert html =~ "oidc.user"
end
test "handles users with missing OIDC ID", %{conn: conn} do

46
test/seeds_test.exs Normal file
View file

@ -0,0 +1,46 @@
defmodule Mv.SeedsTest do
use Mv.DataCase, async: false
describe "Seeds script" do
test "runs successfully without errors" do
# Run the seeds script - should not raise any errors
assert Code.eval_file("priv/repo/seeds.exs")
# Basic smoke test: ensure some data was created
{:ok, users} = Ash.read(Mv.Accounts.User)
{:ok, members} = Ash.read(Mv.Membership.Member)
{:ok, property_types} = Ash.read(Mv.Membership.PropertyType)
assert length(users) > 0, "Seeds should create at least one user"
assert length(members) > 0, "Seeds should create at least one member"
assert length(property_types) > 0, "Seeds should create at least one property type"
end
test "can be run multiple times (idempotent)" do
# Run seeds first time
assert Code.eval_file("priv/repo/seeds.exs")
# Count records
{:ok, users_count_1} = Ash.read(Mv.Accounts.User)
{:ok, members_count_1} = Ash.read(Mv.Membership.Member)
{:ok, property_types_count_1} = Ash.read(Mv.Membership.PropertyType)
# Run seeds second time - should not raise errors
assert Code.eval_file("priv/repo/seeds.exs")
# Count records again - should be the same (upsert, not duplicate)
{:ok, users_count_2} = Ash.read(Mv.Accounts.User)
{:ok, members_count_2} = Ash.read(Mv.Membership.Member)
{:ok, property_types_count_2} = Ash.read(Mv.Membership.PropertyType)
assert length(users_count_1) == length(users_count_2),
"Users count should remain same after re-running seeds"
assert length(members_count_1) == length(members_count_2),
"Members count should remain same after re-running seeds"
assert length(property_types_count_1) == length(property_types_count_2),
"PropertyTypes count should remain same after re-running seeds"
end
end
end

View file

@ -100,11 +100,28 @@ defmodule MvWeb.ConnCase do
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.
"""
def conn_with_oidc_user(conn, user_attrs \\ %{email: "user@example.com"}) do
user = create_test_user(user_attrs)
def conn_with_oidc_user(conn, user_attrs \\ %{}) do
# Ensure unique email for OIDC users
unique_id = System.unique_integer([:positive])
default_attrs = %{
email: "oidc.user#{unique_id}@example.com",
oidc_id: "oidc_#{unique_id}"
}
user = create_test_user(Map.merge(default_attrs, user_attrs))
sign_in_user_via_oidc(conn, user)
end
@doc """
Signs in a user via password authentication and returns a connection with the user authenticated.
"""
def conn_with_password_user(conn, user) do
conn
|> Phoenix.ConnTest.init_test_session(%{})
|> AshAuthentication.Plug.Helpers.store_in_session(user)
end
setup tags do
Mv.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}