Compare commits
99 commits
92588cbef2
...
962f17e89e
| Author | SHA1 | Date | |
|---|---|---|---|
| 962f17e89e | |||
| 56fb0c26e9 | |||
| cdc91aec57 | |||
| 0eab45ebfd | |||
| c1f9750972 | |||
| 8104451d35 | |||
| bb362e1636 | |||
| 41e3a52482 | |||
| b71df98ba2 | |||
| eb42b9fe0a | |||
| 85e1f370f6 | |||
| 9d98ec2494 | |||
| 017ca8b32c | |||
| 3cfae95b1e | |||
| c3502a326e | |||
| d9e48a37d2 | |||
| 687d653fb7 | |||
| 899039b3ee | |||
| 37fcc26b22 | |||
| 1495ef4592 | |||
| 001fca1d16 | |||
| 2693f67d33 | |||
| 7522724945 | |||
| 39afaf3999 | |||
| 5a0a261cd6 | |||
| 91c5e17994 | |||
| b94a4a65d3 | |||
|
|
7882370f4a | ||
| 9a276218c5 | |||
|
|
d8ab0d80db | ||
| 7c295daedc | |||
| eb5fb5bb59 | |||
|
|
210224626d | ||
| e24a731032 | |||
| cbc977514e | |||
| e6169e4287 | |||
| 46a5323cd4 | |||
| c116e39b56 | |||
| 045ae1c3c7 | |||
| 7c1aeddad4 | |||
| 59a8067c09 | |||
| b47b0d36b5 | |||
| 3b0c1da1ab | |||
| cde619543f | |||
| 908517641b | |||
| 23d1ca8a32 | |||
| 515cd76cee | |||
| d8ec828df0 | |||
| 98f4768e00 | |||
| eeed537062 | |||
| 72a8415cb3 | |||
| 5aa9c37742 | |||
| 8dac30a07b | |||
| ce9878791e | |||
| 8d3b76b954 | |||
| 96c5b956e5 | |||
|
|
e0d40014a1 | ||
| b9503da2c3 | |||
| 30616d5a8d | |||
| 00b47a0d3e | |||
| d94f6f2646 | |||
| 2f74ec8ccb | |||
| 9f8b412d23 | |||
| e880fb3c29 | |||
| 8b72235ab3 | |||
| 30c3943884 | |||
| e2c00f263e | |||
| 7d2b719ca2 | |||
| 95d065345b | |||
|
|
d1513d79ed | ||
| bb0fc0a627 | |||
| b132641753 | |||
| 7bedaff145 | |||
| 306734bcee | |||
| fc4dcc3a6c | |||
| 1d334c7da1 | |||
| d10fcc3da1 | |||
| d40bc0bb82 | |||
| 863821f3ae | |||
| e3dd333e89 | |||
| 80b79d80cd | |||
| 6033e33622 | |||
| 2095d9b0da | |||
| e68e1604a4 | |||
| 02b3084789 | |||
| 53f6b62289 | |||
| 78588cbad9 | |||
| dd03000428 | |||
| 52e76b1a99 | |||
| 5559b29ff6 | |||
| a3746dfaaa | |||
| 7118782a2d | |||
|
|
96085ea420 | ||
| 3456f7f6b7 | |||
| 7e2aa49674 | |||
| f0b0de0008 | |||
| 992addf0ea | |||
| 2a6f1ccf80 | |||
| ddf9348eb8 |
83 changed files with 6467 additions and 405 deletions
|
|
@ -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
|
||||
|
|
|
|||
45
.drone.yml
45
.drone.yml
|
|
@ -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:
|
||||
|
|
|
|||
17
.env.example
17
.env.example
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
elixir 1.18.3-otp-27
|
||||
erlang 27.3.4
|
||||
just 1.42.4
|
||||
just 1.43.0
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
6
Justfile
6
Justfile
|
|
@ -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
662
LICENSE
Normal 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
262
README.md
|
|
@ -1,18 +1,266 @@
|
|||
# mitgliederverwaltung
|
||||
# Mila
|
||||
|
||||
## Testing SSO with rauthy
|
||||
**Mila** — simple, usable, self-hostable membership management for small to mid-sized clubs.
|
||||
|
||||
[](https://drone.dev.local-it.cloud/local-it/mitgliederverwaltung)
|
||||

|
||||
|
||||
## 🚧 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 don’t need
|
||||
* **Too expensive** — hidden fees, closed ecosystems, vendor lock-in
|
||||
* **Too rigid** — no way to adapt fields, processes, or roles to your club’s 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
|
||||
|
||||

|
||||
*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.
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
39
docker-compose.prod.yml
Normal 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:
|
||||
|
|
@ -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
49
docs/email-sync.md
Normal 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 |
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
37
lib/mv/email_sync/changes/sync_member_email_to_user.ex
Normal file
37
lib/mv/email_sync/changes/sync_member_email_to_user.ex
Normal 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
|
||||
62
lib/mv/email_sync/changes/sync_user_email_to_member.ex
Normal file
62
lib/mv/email_sync/changes/sync_user_email_to_member.ex
Normal 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
|
||||
93
lib/mv/email_sync/helpers.ex
Normal file
93
lib/mv/email_sync/helpers.ex
Normal 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
|
||||
40
lib/mv/email_sync/loader.ex
Normal file
40
lib/mv/email_sync/loader.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang={Gettext.get_locale()}>
|
||||
<head>
|
||||
{Application.get_env(:live_debugger, :live_debugger_tags)}
|
||||
|
||||
|
|
|
|||
64
lib/mv_web/live/components/search_bar_component.ex
Normal file
64
lib/mv_web/live/components/search_bar_component.ex
Normal 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
|
||||
64
lib/mv_web/live/components/sort_header_component.ex
Normal file
64
lib/mv_web/live/components/sort_header_component.ex
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<Layouts.app flash={@flash}>
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Listing Users")}
|
||||
<:actions>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
6
mix.exs
6
mix.exs
|
|
@ -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",
|
||||
|
|
|
|||
67
mix.lock
67
mix.lock
|
|
@ -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
64
priv/gettext/auth.pot
Normal 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 ""
|
||||
66
priv/gettext/de/LC_MESSAGES/auth.po
Normal file
66
priv/gettext/de/LC_MESSAGES/auth.po
Normal 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"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
63
priv/gettext/en/LC_MESSAGES/auth.po
Normal file
63
priv/gettext/en/LC_MESSAGES/auth.po
Normal 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"
|
||||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
19
priv/repo/migrations/20250926164519_member_relation.exs
Normal file
19
priv/repo/migrations/20250926164519_member_relation.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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!")
|
||||
|
|
|
|||
199
priv/resource_snapshots/repo/members/20250912085235.json
Normal file
199
priv/resource_snapshots/repo/members/20250912085235.json
Normal 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"
|
||||
}
|
||||
202
priv/resource_snapshots/repo/members/20250926180341.json
Normal file
202
priv/resource_snapshots/repo/members/20250926180341.json
Normal 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"
|
||||
}
|
||||
214
priv/resource_snapshots/repo/members/20251016130855.json
Normal file
214
priv/resource_snapshots/repo/members/20251016130855.json
Normal 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"
|
||||
}
|
||||
124
priv/resource_snapshots/repo/properties/20251016130855.json
Normal file
124
priv/resource_snapshots/repo/properties/20251016130855.json
Normal 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"
|
||||
}
|
||||
141
priv/resource_snapshots/repo/users/20250926164519.json
Normal file
141
priv/resource_snapshots/repo/users/20250926164519.json
Normal 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"
|
||||
}
|
||||
141
priv/resource_snapshots/repo/users/20251016130855.json
Normal file
141
priv/resource_snapshots/repo/users/20251016130855.json
Normal 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"
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"schedule": ["* * 3 * *"],
|
||||
"schedule": ["* * 1-7 * *"],
|
||||
"packageRules": [
|
||||
{
|
||||
"groupName": "Mix dependencies",
|
||||
|
|
|
|||
93
test/accounts/email_sync_edge_cases_test.exs
Normal file
93
test/accounts/email_sync_edge_cases_test.exs
Normal 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
|
||||
480
test/accounts/email_uniqueness_test.exs
Normal file
480
test/accounts/email_uniqueness_test.exs
Normal 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
|
||||
169
test/accounts/user_email_sync_test.exs
Normal file
169
test/accounts/user_email_sync_test.exs
Normal 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
|
||||
88
test/accounts/user_member_deletion_test.exs
Normal file
88
test/accounts/user_member_deletion_test.exs
Normal 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
|
||||
197
test/accounts/user_member_relationship_test.exs
Normal file
197
test/accounts/user_member_relationship_test.exs
Normal 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
|
||||
127
test/membership/member_email_sync_test.exs
Normal file
127
test/membership/member_email_sync_test.exs
Normal 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
|
||||
88
test/mv_web/components/layouts/navbar_test.exs
Normal file
88
test/mv_web/components/layouts/navbar_test.exs
Normal 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
|
||||
33
test/mv_web/components/search_bar_component_test.exs
Normal file
33
test/mv_web/components/search_bar_component_test.exs
Normal 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
|
||||
319
test/mv_web/components/sort_header_component_test.exs
Normal file
319
test/mv_web/components/sort_header_component_test.exs
Normal 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
|
||||
182
test/mv_web/live/profile_navigation_test.exs
Normal file
182
test/mv_web/live/profile_navigation_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
46
test/seeds_test.exs
Normal 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
|
||||
|
|
@ -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()}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue