feat(passwordless): pass3: passwordless account (via emailed magic-link)

Backend
  - RegisterDto — username and password both become Option<String> with #[serde(default)] so JSON can omit them entirely.
  - AuthApplicationService::register — username uniqueness check skipped when None (multiple NULLs OK under the UNIQUE index); password hashing skipped when None; User::new called with the actual Options instead of forcing Some(...).
  - auth_handler::register — branches on dto.password.is_none(). With password → existing 201 + UserDto. Without → triggers MagicLinkInviteService::send_login_link(&email) best-effort, then returns 200 + {"message": "Check your email…"}. The
  OIDC-mode-disables-password-registration gate now only fires for the password path (email-only signup is still allowed even in OIDC-only mode, because it doesn't store a password).
  - magic_link_handler::redirect_target — new 3-way decision tree:
    - Resource target (folder invitation) → /#/files/folder/{id} (existing)
    - NULL resource + is_external = false → /#/files (the welcome path for new internal users — they have a home folder)
    - NULL resource + is_external = true → /#/sharedwithme (the existing external-user landing)

  Tests
  - New tests/api/registration.hurl with 9 requests covering: classic (with-password) register → 201 + UserDto, email-only register → 200 + uniform message + welcome magic-link captured, redemption → 302 to /#/files + cookies set, profile read → username
  absent + is_external: false, resend magic-link works (eligible while passwordless), cleanup deletes both new users.
  - Wired into tests/api/run.sh right after auth_login.hurl.

  Plan additions
  - auth-simplification.md gained PR 22 at the bottom of the PR sequence — device-bound magic-link redemption via challenge cookie + asymmetric TTLs (login: 10 min, invitation: 24 h). Full design recap, schema migration, config knobs
  (OXICLOUD_MAGIC_LINK_LOGIN_TTL_MINUTES / _INVITE_TTL_HOURS), and Hurl coverage outline are in the plan. Slots in before PR 21's docs so the architecture page describes the final state from the start.

  Checks — cargo fmt, cargo clippy --all-features --all-targets -- -D warnings, cargo test --lib (297 passed), biome, stylelint, tsc, full Hurl suite (16 files) all green.
This commit is contained in:
Edouard Vanbelle
2026-06-02 22:26:11 +02:00
parent 054997d7f6
commit 9a49ab44d8
6 changed files with 252 additions and 54 deletions
@@ -101,7 +101,7 @@ async fn redeem_magic_link(
}
fn build_success_response(state: &Arc<AppState>, redemption: MagicLinkRedemption) -> Response {
let target = redirect_target(redemption.resource_kind, redemption.resource_id);
let target = redirect_target(&redemption);
let mut response = (StatusCode::FOUND, [(LOCATION, target.as_str())]).into_response();
@@ -119,12 +119,23 @@ fn build_success_response(state: &Arc<AppState>, redemption: MagicLinkRedemption
/// Build the SPA hash-route the redemption should land on. Mirrors the
/// front-end's `deserializeHash()` parser at `static/js/app/main.js`.
fn redirect_target(kind: Option<MagicLinkResourceKind>, id: Option<uuid::Uuid>) -> String {
match (kind, id) {
///
/// - **Resource token** (folder invitation): deep-link to the resource.
/// - **NULL-resource token + external user**: land on `/#/sharedwithme`
/// (their entry point — they own no folders themselves).
/// - **NULL-resource token + internal user**: land on `/#/files` (the
/// user has a home folder; the "shared with me" view would be empty
/// on first signup, so home is the better welcome). Internal users
/// on NULL-resource tokens come from the email-only-signup welcome
/// path (PR 18) or from a magic-link they requested themselves
/// while password-eligible-and-lenient-mode (PR 19).
fn redirect_target(redemption: &MagicLinkRedemption) -> String {
match (redemption.resource_kind, redemption.resource_id) {
(Some(MagicLinkResourceKind::Folder), Some(folder_id)) => {
format!("/#/files/folder/{}", folder_id)
}
_ => "/#/sharedwithme".to_string(),
_ if redemption.auth.user.is_external => "/#/sharedwithme".to_string(),
_ => "/#/files".to_string(),
}
}