mitgliederverwaltung/lib/membership/join_request.ex
Simon 86d9242d83
All checks were successful
continuous-integration/drone/push Build is passing
feat: add approval ui for join requests
2026-03-11 02:04:03 +01:00

203 lines
7.1 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Mv.Membership.JoinRequest do
@moduledoc """
Ash resource for public join requests (onboarding, double opt-in).
A JoinRequest is created on form submit with status `pending_confirmation`, then
updated to `submitted` when the user clicks the confirmation link. No User or
Member is created in this flow; promotion happens in a later approval step.
## Public actions (actor: nil)
- `submit` (create) create with token hash and expiry
- `get_by_confirmation_token_hash` (read) lookup by token hash for confirm flow
- `confirm` (update) set status to submitted and invalidate token
## Schema
Typed: email (required), first_name, last_name. Remaining form data in form_data (jsonb).
Confirmation: confirmation_token_hash, confirmation_token_expires_at. Audit: submitted_at, etc.
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "join_requests"
repo Mv.Repo
end
actions do
defaults [:read, :destroy]
create :submit do
description "Create a join request (public form submit); stores token hash and expiry"
primary? true
argument :confirmation_token, :string, allow_nil?: false
accept [:email, :first_name, :last_name, :form_data, :schema_version]
change Mv.Membership.JoinRequest.Changes.SetConfirmationToken
change Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist
end
# Internal/seeding only: create with status submitted (no policy allows; use authorize?: false).
create :create_submitted do
description "Create a join request with status submitted (seeds, internal use only)"
accept [:email, :first_name, :last_name, :form_data, :schema_version]
change Mv.Membership.JoinRequest.Changes.SetSubmittedForSeeding
end
read :get_by_confirmation_token_hash do
description "Find a join request by confirmation token hash (for confirm flow only)"
argument :confirmation_token_hash, :string, allow_nil?: false
filter expr(confirmation_token_hash == ^arg(:confirmation_token_hash))
prepare build(sort: [inserted_at: :desc], limit: 1)
end
update :confirm do
description "Mark join request as submitted and invalidate token (after link click)"
primary? true
require_atomic? false
change Mv.Membership.JoinRequest.Changes.ConfirmRequest
end
update :approve do
description "Approve a submitted join request and promote to Member"
require_atomic? false
change Mv.Membership.JoinRequest.Changes.ApproveRequest
end
update :reject do
description "Reject a submitted join request"
require_atomic? false
change Mv.Membership.JoinRequest.Changes.RejectRequest
end
end
policies do
# Use :strict so unauthorized access returns Forbidden (not empty list).
# Default :filter would silently return [] for unauthorized reads instead of Forbidden.
default_access_type :strict
# Public actions: bypass so nil actor is immediately authorized (skips all remaining policies).
# Using bypass (not policy) avoids AND-combination with the read policy below.
bypass action(:submit) do
description "Allow unauthenticated submit (public join form)"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
bypass action(:get_by_confirmation_token_hash) do
description "Allow unauthenticated lookup by token hash for confirm"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
bypass action(:confirm) do
description "Allow unauthenticated confirm (confirmation link click)"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
# READ: bypass for authorized roles (normal_user, admin).
# Uses a SimpleCheck (HasJoinRequestAccess) to avoid HasPermission.auto_filter returning
# expr(false), which would silently produce an empty list instead of Forbidden for
# unauthorized actors. See docs/policy-bypass-vs-haspermission.md.
# Unauthorized actors fall through to no matching policy → Ash default deny (Forbidden).
bypass action_type(:read) do
description "Allow normal_user and admin to read join requests (SimpleCheck bypass)"
authorize_if Mv.Authorization.Checks.HasJoinRequestAccess
end
# Approve/Reject: only actors with JoinRequest update permission
policy action(:approve) do
description "Allow authenticated users with JoinRequest update permission to approve"
authorize_if Mv.Authorization.Checks.HasPermission
end
policy action(:reject) do
description "Allow authenticated users with JoinRequest update permission to reject"
authorize_if Mv.Authorization.Checks.HasPermission
end
end
validations do
# Format/formatting of email is not validated here; invalid addresses may fail at send time
# or can be enforced via an Ash change if needed.
validate present(:email), on: [:create]
end
# Attributes are backend-internal for now; set public? true when exposing via AshJsonApi/AshGraphql
attributes do
uuid_primary_key :id
attribute :status, :atom do
description "pending_confirmation | submitted | approved | rejected"
default :pending_confirmation
constraints one_of: [:pending_confirmation, :submitted, :approved, :rejected]
allow_nil? false
end
attribute :email, :string do
description "Email address (required for join form)"
allow_nil? false
end
attribute :first_name, :string
attribute :last_name, :string
attribute :form_data, :map do
description "Additional form fields (jsonb)"
end
attribute :schema_version, :integer do
description "Version of join form / member_fields for form_data"
end
attribute :confirmation_token_hash, :string do
description "SHA256 hash of confirmation token; raw token only in email link"
end
attribute :confirmation_token_expires_at, :utc_datetime_usec do
description "When the confirmation link expires (e.g. 24h)"
end
attribute :confirmation_sent_at, :utc_datetime_usec do
description "When the confirmation email was sent"
end
attribute :submitted_at, :utc_datetime_usec do
description "When the user confirmed (clicked the link)"
end
attribute :approved_at, :utc_datetime_usec
attribute :rejected_at, :utc_datetime_usec
attribute :reviewed_by_user_id, :uuid
attribute :source, :string
create_timestamp :inserted_at
update_timestamp :updated_at
end
relationships do
belongs_to :reviewed_by_user, Mv.Accounts.User do
define_attribute? false
source_attribute :reviewed_by_user_id
end
end
# Public helpers (used by SetConfirmationToken change and domain confirm_join_request)
@doc """
Returns the SHA256 hash of the confirmation token (lowercase hex).
Used when creating a join request (submit) and when confirming by token.
Only one implementation ensures algorithm changes stay in sync.
"""
@spec hash_confirmation_token(String.t()) :: String.t()
def hash_confirmation_token(token) when is_binary(token) do
:crypto.hash(:sha256, token) |> Base.encode16(case: :lower)
end
end