feat(magiclink) prepare magic link support (login via email)

imortant on security side: magic link  will be enabled only for users who don't have password nor OIDC
This commit is contained in:
Edouard Vanbelle
2026-06-01 21:57:07 +02:00
parent 2011d19e71
commit c3fa1b3e93
15 changed files with 1076 additions and 56 deletions
@@ -0,0 +1,49 @@
//! Storage port for [`MagicLinkToken`].
//!
//! Minimal CRUD surface — magic-link tokens have only three lifecycle
//! states (`Pending`, `Used`, `Expired`) and three callers (mint at invite
//! time, redeem at click time, sweep at maintenance time). New methods
//! should be resisted until a concrete consumer needs them.
use async_trait::async_trait;
use uuid::Uuid;
use crate::common::errors::DomainError;
use crate::domain::entities::magic_link_token::MagicLinkToken;
#[async_trait]
pub trait MagicLinkTokenRepository: Send + Sync + 'static {
/// Persist a freshly-minted pending token.
async fn create(&self, token: &MagicLinkToken) -> Result<(), DomainError>;
/// Look up a token by its opaque value. Returns `Ok(None)` when no row
/// matches (use this for "unknown token" rather than treating it as an
/// error). The caller is responsible for checking `is_redeemable()`
/// before honouring the token.
async fn find_by_token(&self, token: &str) -> Result<Option<MagicLinkToken>, DomainError>;
/// Atomically transition a token from `Pending` → `Used`. Returns
/// `Ok(true)` exactly when this call performed the transition; a
/// concurrent redemption attempt receives `Ok(false)` and must reject
/// the request. Implementations MUST do this in a single SQL
/// statement (`UPDATE … WHERE status='pending' …`) — the row-level
/// lock provided by Postgres' MVCC is what makes single-use
/// enforcement race-free.
async fn mark_used(&self, id: Uuid) -> Result<bool, DomainError>;
/// Delete every token that has expired (status pending, expires_at
/// in the past). Returns the number of rows removed; called from a
/// background sweeper that runs on a slow cadence (≤ once per hour).
async fn delete_expired(&self) -> Result<u64, DomainError>;
/// Hard-delete every still-outstanding token for a user. Called by
/// the user-lifecycle `on_user_deleted` hook so an admin's delete
/// can't leave dangling tokens behind. Operates inside the caller's
/// transaction so the cleanup commits atomically with the user
/// DELETE.
async fn delete_all_for_user_tx(
&self,
user_id: Uuid,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<u64, DomainError>;
}
+1
View File
@@ -4,6 +4,7 @@ pub mod calendar_repository;
pub mod contact_repository;
pub mod file_repository;
pub mod folder_repository;
pub mod magic_link_token_repository;
pub mod playlist_repository;
pub mod session_repository;
pub mod settings_repository;