diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 63a3c44..7c25133 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -41,6 +41,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -834,6 +884,46 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + [[package]] name = "cmake" version = "0.1.54" @@ -843,6 +933,12 @@ dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -1909,6 +2005,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -2228,6 +2330,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl" version = "0.10.74" @@ -2313,6 +2421,7 @@ dependencies = [ "base64 0.21.7", "bytes", "chrono", + "clap", "diesel", "diesel_migrations", "dotenv", @@ -3781,6 +3890,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "utoipa" version = "4.2.3" @@ -4143,6 +4258,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index c8c454b..4faeca0 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -48,6 +48,7 @@ futures-util = "0.3" url = "2.5" once_cell = "1.19" utoipa = { version = "4.2", default-features = false, features = ["chrono", "uuid", "preserve_order"] } +clap = { version = "4.5", features = ["derive"] } # Error handling thiserror = "1.0" diff --git a/backend/migrations/202510310000_create_magic_tokens/down.sql b/backend/migrations/202510310000_create_magic_tokens/down.sql new file mode 100644 index 0000000..cdbb5ed --- /dev/null +++ b/backend/migrations/202510310000_create_magic_tokens/down.sql @@ -0,0 +1,2 @@ +DROP TABLE magic_tokens; +DROP TYPE magic_token_kind; diff --git a/backend/migrations/202510310000_create_magic_tokens/up.sql b/backend/migrations/202510310000_create_magic_tokens/up.sql new file mode 100644 index 0000000..736674e --- /dev/null +++ b/backend/migrations/202510310000_create_magic_tokens/up.sql @@ -0,0 +1,18 @@ +CREATE TYPE magic_token_kind AS ENUM ('email_login', 'demo_login'); + +CREATE TABLE magic_tokens ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind magic_token_kind NOT NULL, + token_hash VARCHAR NOT NULL UNIQUE, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + expires_at TIMESTAMPTZ NOT NULL, + max_uses INTEGER, + used_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + last_used_at TIMESTAMPTZ +); + +CREATE INDEX magic_tokens_token_hash_idx ON magic_tokens (token_hash); +CREATE INDEX magic_tokens_expires_at_idx ON magic_tokens (expires_at); diff --git a/backend/src/bin/admin.rs b/backend/src/bin/admin.rs index 1100521..75861e5 100644 --- a/backend/src/bin/admin.rs +++ b/backend/src/bin/admin.rs @@ -1,9 +1,12 @@ -use std::env; use std::sync::Arc; use anyhow::{anyhow, bail, Context, Result}; +use chrono::{Duration as ChronoDuration, Utc}; +use clap::{Parser, Subcommand, ValueEnum}; use diesel::{dsl::exists, prelude::*, select}; +use rand::{rngs::OsRng, RngCore}; use reqwest::{Client, Method, StatusCode}; +use sha2::{Digest, Sha256}; use uuid::Uuid; use papercrate::{ @@ -12,18 +15,31 @@ use papercrate::{ documents::search::ensure_quickwit_index, jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT}, models::{ - DocumentAsset, DocumentAssetObject, NewUser, NewUserMembership, Tenant, TenantStatus, User, + DocumentAsset, DocumentAssetObject, MagicToken, MagicTokenKind, NewUser, NewUserMembership, + Tenant, TenantStatus, User, }, s3, schema::{ - document_asset_objects, document_assets, documents, tenants, user_memberships, users, + document_asset_objects, document_assets, documents, magic_tokens, tenants, + user_memberships, users, }, storage::{ObjectStorage, S3Storage, TenantStorage}, tenants::TenantService, utils::tracing::init_tracing, }; -#[derive(Debug)] +#[derive(Parser)] +#[command( + name = "papercrate-admin", + version, + about = "Papercrate administration utility" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] enum Command { CreateUser { username: String, @@ -34,7 +50,9 @@ enum Command { }, CreateTenant { name: String, + #[arg(long = "storage-root")] storage_root: Option, + #[arg(long = "quickwit-index")] quickwit_index: Option, }, DeleteTenant { @@ -52,71 +70,43 @@ enum Command { tenant_id: Uuid, }, ListTenants, - DeleteAssets(Uuid), - QuickwitCreate(Uuid), - QuickwitDelete(Uuid), + DeleteAssets { + tenant_id: Uuid, + }, + QuickwitCreate { + tenant_id: Uuid, + }, + QuickwitDelete { + tenant_id: Uuid, + }, + MagicToken { + username: String, + #[arg(long = "ttl-minutes", default_value_t = 10)] + ttl_minutes: i64, + #[arg( + long = "max-uses", + value_name = "MAX_USES", + help = "Maximum number of uses before the token is rejected (default: unlimited)" + )] + max_uses: Option, + #[arg(long = "kind", value_enum, default_value_t = MagicTokenKindArg::EmailLogin)] + kind: MagicTokenKindArg, + }, } -impl Command { - fn usage() -> &'static str { - "Usage: admin\n\ - create-user \n\ - list-users\n\ - delete-user \n\ - create-tenant [storage_root] [quickwit_index]\n\ - delete-tenant \n\ - add-user-to-tenant \n\ - remove-user-from-tenant \n\ - reanalyze-documents \n\ - list-tenants\n\ - delete-assets \n\ - quickwit-create-index \n\ - quickwit-delete-index " - } +#[derive(Copy, Clone, Debug, ValueEnum)] +enum MagicTokenKindArg { + #[value(name = "email_login")] + EmailLogin, + #[value(name = "demo_login")] + DemoLogin, +} - fn parse_tenant_id(arg: Option) -> Result { - let raw = arg.ok_or_else(|| anyhow!("tenant id required"))?; - Uuid::parse_str(&raw).map_err(|_| anyhow!("invalid tenant id: {}", raw)) - } - - fn parse() -> Result { - let mut args = env::args().skip(1); - match args.next().as_deref() { - Some("create-user") => Ok(Self::CreateUser { - username: args.next().ok_or_else(|| anyhow!("username required"))?, - }), - Some("list-users") => Ok(Self::ListUsers), - Some("delete-user") => Ok(Self::DeleteUser { - username: args.next().ok_or_else(|| anyhow!("username required"))?, - }), - Some("create-tenant") => Ok(Self::CreateTenant { - name: args.next().ok_or_else(|| anyhow!("tenant name required"))?, - storage_root: args.next(), - quickwit_index: args.next(), - }), - Some("delete-tenant") => Ok(Self::DeleteTenant { - tenant_id: Self::parse_tenant_id(args.next())?, - }), - Some("add-user-to-tenant") => Ok(Self::AddUserToTenant { - username: args.next().ok_or_else(|| anyhow!("username required"))?, - tenant_id: Self::parse_tenant_id(args.next())?, - }), - Some("remove-user-from-tenant") => Ok(Self::RemoveUserFromTenant { - username: args.next().ok_or_else(|| anyhow!("username required"))?, - tenant_id: Self::parse_tenant_id(args.next())?, - }), - Some("reanalyze-documents") => Ok(Self::ReanalyzeDocuments { - tenant_id: Self::parse_tenant_id(args.next())?, - }), - Some("list-tenants") => Ok(Self::ListTenants), - Some("delete-assets") => Ok(Self::DeleteAssets(Self::parse_tenant_id(args.next())?)), - Some("quickwit-create-index") => { - Ok(Self::QuickwitCreate(Self::parse_tenant_id(args.next())?)) - } - Some("quickwit-delete-index") => { - Ok(Self::QuickwitDelete(Self::parse_tenant_id(args.next())?)) - } - _ => Err(anyhow!(Self::usage())), +impl From for MagicTokenKind { + fn from(value: MagicTokenKindArg) -> Self { + match value { + MagicTokenKindArg::EmailLogin => MagicTokenKind::EmailLogin, + MagicTokenKindArg::DemoLogin => MagicTokenKind::DemoLogin, } } } @@ -124,11 +114,11 @@ impl Command { #[tokio::main] async fn main() -> Result<()> { init_tracing("info"); - let command = Command::parse()?; + let cli = Cli::parse(); let config = AppConfig::load_and_log("admin")?; let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?; - match command { + match cli.command { Command::CreateUser { username } => create_user(&pool, &username)?, Command::ListUsers => list_users(&pool)?, Command::DeleteUser { username } => delete_user(&pool, &username)?, @@ -148,15 +138,23 @@ async fn main() -> Result<()> { } => remove_user_from_tenant(&pool, &username, tenant_id)?, Command::ReanalyzeDocuments { tenant_id } => reanalyze_documents(&pool, tenant_id)?, Command::ListTenants => list_tenants(&pool)?, - Command::DeleteAssets(tenant_id) => { + Command::DeleteAssets { tenant_id } => { delete_assets_for_tenant(&config, &pool, tenant_id).await? } - Command::QuickwitCreate(tenant_id) => { + Command::QuickwitCreate { tenant_id } => { quickwit_index(&config, &pool, tenant_id, Method::POST).await? } - Command::QuickwitDelete(tenant_id) => { + Command::QuickwitDelete { tenant_id } => { quickwit_index(&config, &pool, tenant_id, Method::DELETE).await? } + Command::MagicToken { + username, + ttl_minutes, + max_uses, + kind, + } => { + create_magic_token(&pool, &username, ttl_minutes, max_uses, kind.into())?; + } } Ok(()) @@ -268,6 +266,66 @@ fn create_tenant( Ok(()) } +fn create_magic_token( + pool: &PgPool, + username: &str, + ttl_minutes: i64, + max_uses: Option, + kind: MagicTokenKind, +) -> Result<()> { + let mut conn = pool.get().context("failed to get database connection")?; + + let user: User = users::table + .filter(users::username.eq(username)) + .first(&mut conn) + .with_context(|| format!("user '{}' not found", username))?; + + let raw_token = generate_random_token(); + let token_hash = hash_token(&raw_token); + let expires_at = Utc::now() + ChronoDuration::minutes(ttl_minutes); + + let new_token = MagicToken { + id: Uuid::new_v4(), + user_id: user.id, + kind, + token_hash, + metadata: serde_json::json!({}), + expires_at: expires_at.naive_utc(), + max_uses, + used_count: 0, + created_at: Utc::now().naive_utc(), + created_by: None, + last_used_at: None, + }; + + diesel::insert_into(magic_tokens::table) + .values(&new_token) + .execute(&mut conn)?; + + println!( + "Magic token created for '{}' (kind: {}, expires_at: {}, max_uses: {})", + username, + kind.as_str(), + expires_at, + max_uses.map_or("∞".to_string(), |v| v.to_string()) + ); + println!("Token: {}", raw_token); + + Ok(()) +} + +fn generate_random_token() -> String { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + hex::encode(bytes) +} + +fn hash_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) +} + fn delete_tenant(pool: &PgPool, tenant_id: Uuid) -> Result<()> { let mut conn = pool.get().context("failed to get database connection")?; diff --git a/backend/src/models.rs b/backend/src/models.rs index 3273eaa..b0fb9c4 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -10,7 +10,9 @@ use std::io::Write; use std::str; use uuid::Uuid; -use crate::schema::sql_types::TenantStatus as TenantStatusSql; +use crate::schema::sql_types::{ + MagicTokenKind as MagicTokenKindSql, TenantStatus as TenantStatusSql, +}; use crate::schema::*; #[derive(Debug, Clone, Queryable, Identifiable, Associations)] @@ -43,6 +45,64 @@ pub enum TenantStatus { Error, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)] +#[diesel(sql_type = MagicTokenKindSql)] +pub enum MagicTokenKind { + EmailLogin, + DemoLogin, +} + +impl MagicTokenKind { + pub fn as_str(&self) -> &'static str { + match self { + MagicTokenKind::EmailLogin => "email_login", + MagicTokenKind::DemoLogin => "demo_login", + } + } + + pub fn variants() -> &'static [&'static str] { + &["email_login", "demo_login"] + } +} + +impl fmt::Display for MagicTokenKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl ToSql for MagicTokenKind { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + out.write_all(self.as_str().as_bytes())?; + Ok(IsNull::No) + } +} + +impl FromSql for MagicTokenKind { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + match std::str::from_utf8(bytes.as_bytes())? { + "email_login" => Ok(MagicTokenKind::EmailLogin), + "demo_login" => Ok(MagicTokenKind::DemoLogin), + other => Err(Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("invalid magic_token_kind '{other}'"), + ))), + } + } +} + +impl str::FromStr for MagicTokenKind { + type Err = &'static str; + + fn from_str(value: &str) -> Result { + match value { + "email_login" => Ok(MagicTokenKind::EmailLogin), + "demo_login" => Ok(MagicTokenKind::DemoLogin), + _ => Err("unsupported magic token kind"), + } + } +} + impl TenantStatus { pub fn as_str(&self) -> &'static str { match self { @@ -265,6 +325,22 @@ pub struct NewDocument { pub tenant_id: Uuid, } +#[derive(Debug, Clone, Queryable, Identifiable, Insertable)] +#[diesel(table_name = magic_tokens)] +pub struct MagicToken { + pub id: Uuid, + pub user_id: Uuid, + pub kind: MagicTokenKind, + pub token_hash: String, + pub metadata: serde_json::Value, + pub expires_at: NaiveDateTime, + pub max_uses: Option, + pub used_count: i32, + pub created_at: NaiveDateTime, + pub created_by: Option, + pub last_used_at: Option, +} + #[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[diesel(table_name = document_versions)] #[diesel(belongs_to(Document))] diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 6f467b8..2cd5d8e 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -25,10 +25,13 @@ use crate::{ AuthenticatedUser, }, error::{AppError, AppResult}, - models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership}, + models::{ + MagicToken, MagicTokenKind, NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, + User, UserMembership, + }, schema::{ - refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl, - user_passkeys::dsl as passkey_dsl, users::dsl, + magic_tokens::dsl as magic_dsl, refresh_tokens, tenants::dsl as tenant_dsl, + user_memberships::dsl as memberships_dsl, user_passkeys::dsl as passkey_dsl, users::dsl, }, state::AppState, }; @@ -40,8 +43,14 @@ const REFRESH_COOKIE_NAME: &str = "refresh_token"; #[derive(Deserialize, ToSchema)] pub struct LoginRequest { + #[serde(default)] pub username: String, - pub password: String, + #[serde(default)] + #[schema(nullable)] + pub password: Option, + #[serde(default)] + #[schema(nullable)] + pub magic_token: Option, #[serde(default)] #[schema(nullable)] pub preferred_tenant_id: Option, @@ -152,10 +161,41 @@ pub struct AuthApiDoc; ), tag = "Auth" )] -pub async fn login(_state: State, _payload: Json) -> AppResult { - Err(AppError::bad_request( - "password authentication is no longer supported", - )) +pub async fn login( + State(state): State, + Json(payload): Json, +) -> AppResult { + let magic_token = payload + .magic_token + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()); + + if magic_token.is_none() { + if payload.password.is_some() { + return Err(AppError::bad_request( + "password authentication is no longer supported", + )); + } + + return Err(AppError::bad_request( + "magic_token is required for passwordless login", + )); + } + + let token_value = magic_token.unwrap(); + + let mut conn = state.db_unscoped()?; + let username_hint = payload.username.trim(); + let preferred_tenant_id = payload.preferred_tenant_id; + + magic_token_login( + &state, + &mut conn, + token_value, + (!username_hint.is_empty()).then_some(username_hint), + preferred_tenant_id, + ) } #[utoipa::path( @@ -644,6 +684,63 @@ fn complete_login( .into_response()) } +fn magic_token_login( + state: &AppState, + conn: &mut PgConnection, + token_value: &str, + username_hint: Option<&str>, + preferred_tenant_id: Option, +) -> AppResult { + if token_value.is_empty() { + return Err(AppError::bad_request("magic_token must not be empty")); + } + + let token_hash = hash_magic_token(token_value); + let now = Utc::now(); + let now_naive = now.naive_utc(); + + conn.transaction::(|conn| { + let magic = magic_dsl::magic_tokens + .filter(magic_dsl::token_hash.eq(&token_hash)) + .filter(magic_dsl::expires_at.gt(now_naive)) + .first::(conn) + .map_err(|err| match err { + diesel::result::Error::NotFound => AppError::unauthorized(), + _ => AppError::from(err), + })?; + + if let Some(limit) = magic.max_uses { + if magic.used_count >= limit { + return Err(AppError::unauthorized()); + } + } + + match magic.kind { + MagicTokenKind::EmailLogin | MagicTokenKind::DemoLogin => {} + } + + let user: User = dsl::users + .find(magic.user_id) + .first(conn) + .map_err(AppError::from)?; + + if let Some(expected) = username_hint { + if expected != user.username { + return Err(AppError::unauthorized()); + } + } + + diesel::update(magic_dsl::magic_tokens.filter(magic_dsl::id.eq(magic.id))) + .set(( + magic_dsl::used_count.eq(magic.used_count + 1), + magic_dsl::last_used_at.eq(Some(now_naive)), + )) + .execute(conn)?; + + complete_login(state, conn, &user, preferred_tenant_id) + }) +} + fn issue_session( state: &AppState, conn: &mut PgConnection, @@ -704,6 +801,10 @@ fn hash_refresh_token(token: &str) -> String { hex::encode(hasher.finalize()) } +fn hash_magic_token(token: &str) -> String { + hash_refresh_token(token) +} + fn generate_refresh_token() -> String { let mut bytes = [0u8; 32]; OsRng.fill_bytes(&mut bytes); diff --git a/backend/src/schema.rs b/backend/src/schema.rs index bcb4116..8125b78 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -1,6 +1,10 @@ // @generated automatically by Diesel CLI. pub mod sql_types { + #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "magic_token_kind"))] + pub struct MagicTokenKind; + #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "tenant_status"))] pub struct TenantStatus; @@ -127,6 +131,25 @@ diesel::table! { } } +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::MagicTokenKind; + + magic_tokens (id) { + id -> Uuid, + user_id -> Uuid, + kind -> MagicTokenKind, + token_hash -> Varchar, + metadata -> Jsonb, + expires_at -> Timestamptz, + max_uses -> Nullable, + used_count -> Int4, + created_at -> Timestamptz, + created_by -> Nullable, + last_used_at -> Nullable, + } +} + diesel::table! { refresh_tokens (id) { id -> Uuid, @@ -275,6 +298,7 @@ diesel::allow_tables_to_appear_in_same_query!( documents, folders, jobs, + magic_tokens, refresh_tokens, tags, tenants, diff --git a/frontend/src/app/LoginRoute.jsx b/frontend/src/app/LoginRoute.jsx index 027164a..fcf7370 100644 --- a/frontend/src/app/LoginRoute.jsx +++ b/frontend/src/app/LoginRoute.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Navigate, useLocation } from 'react-router-dom'; import LoginView from '../login/LoginView'; import useApiError from '../hooks/useApiError'; @@ -21,6 +21,60 @@ const LoginRoute = () => { const [passkeyLoading, setPasskeyLoading] = useState(false); const signupSupported = passkeySupported; const [signupLoading, setSignupLoading] = useState(false); + const [magicLoginPending, setMagicLoginPending] = useState(false); + const magicLoginParams = useMemo(() => { + const extract = (searchString) => { + const params = new URLSearchParams(searchString || ''); + const token = (params.get('magic_token') || '').trim(); + const usernameHint = (params.get('username') || '').trim(); + const preferredTenantId = (params.get('preferred_tenant_id') || '').trim(); + return { + token: token || null, + username: usernameHint || null, + preferredTenantId: preferredTenantId || null, + }; + }; + + let combined = extract(location.search); + + if (typeof window !== 'undefined') { + const hash = window.location.hash || ''; + const queryIndex = hash.indexOf('?'); + if (queryIndex !== -1) { + const hashQuery = hash.slice(queryIndex + 1); + const hashParams = extract(`?${hashQuery}`); + combined = { + token: combined.token || hashParams.token, + username: combined.username || hashParams.username, + preferredTenantId: combined.preferredTenantId || hashParams.preferredTenantId, + }; + } + if (!combined.token) { + const searchParams = extract(window.location.search); + combined = { + token: combined.token || searchParams.token, + username: combined.username || searchParams.username, + preferredTenantId: combined.preferredTenantId || searchParams.preferredTenantId, + }; + } + } + + return combined; + }, [location.search]); + const preferredTenantRef = useRef(null); + const attemptedMagicTokenRef = useRef(null); + const magicLoginPendingRef = useRef(false); + const appStatusRef = useRef(appStatus); + + const { + token: magicToken, + username: magicUsername, + preferredTenantId: magicPreferredTenantId, + } = magicLoginParams; + + useEffect(() => { + appStatusRef.current = appStatus; + }, [appStatus]); const setStatusMessage = useCallback((message, variant = 'info') => { setStatus(message ? { message, variant } : null); @@ -41,6 +95,50 @@ const LoginRoute = () => { [reportLoginError], ); + const clearMagicParamsFromUrl = useCallback(() => { + if (typeof window === 'undefined') { + return; + } + + const removableKeys = ['magic_token', 'username', 'preferred_tenant_id']; + const currentSearch = new URLSearchParams(window.location.search); + let searchChanged = false; + removableKeys.forEach((key) => { + if (currentSearch.has(key)) { + currentSearch.delete(key); + searchChanged = true; + } + }); + + const hash = window.location.hash || ''; + let nextHash = hash; + const hashQuestionIndex = hash.indexOf('?'); + if (hashQuestionIndex !== -1) { + const hashPath = hash.slice(0, hashQuestionIndex); + const hashQuery = hash.slice(hashQuestionIndex + 1); + const hashParams = new URLSearchParams(hashQuery); + let hashChanged = false; + removableKeys.forEach((key) => { + if (hashParams.has(key)) { + hashParams.delete(key); + hashChanged = true; + } + }); + if (hashChanged) { + const nextQuery = hashParams.toString(); + nextHash = nextQuery ? `${hashPath}?${nextQuery}` : hashPath; + } + } + + if (!searchChanged && nextHash === hash) { + return; + } + + const nextSearch = currentSearch.toString(); + const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${nextHash}`; + window.history.replaceState(window.history.state, document.title, nextUrl); + }, []); + const handleTenantSelect = useCallback( async (tenant) => { if (!tenantSelection?.selectionToken || !tenant?.id) { @@ -165,6 +263,122 @@ const LoginRoute = () => { [appDispatch, notifyLoginError, passkeySupported, setStatusMessage], ); + useEffect(() => { + if (!magicToken) { + return; + } + if (magicLoginPendingRef.current) { + return; + } + if (attemptedMagicTokenRef.current === magicToken) { + return; + } + if (appStatusRef.current === 'authenticated') { + clearMagicParamsFromUrl(); + return; + } + + let cancelled = false; + attemptedMagicTokenRef.current = magicToken; + + const attemptMagicLogin = async () => { + magicLoginPendingRef.current = true; + setMagicLoginPending(true); + preferredTenantRef.current = magicPreferredTenantId; + appDispatch({ type: 'LOGIN_REQUEST' }); + setStatusMessage('Signing you in…', 'info'); + + try { + const payload = { + magic_token: magicToken, + }; + if (magicUsername) { + payload.username = magicUsername; + } + if (magicPreferredTenantId) { + payload.preferred_tenant_id = magicPreferredTenantId; + } + + const { data } = await api.post('/auth/login', payload); + if (cancelled) { + return; + } + + if (data?.access_token && Array.isArray(data?.tenants)) { + appDispatch({ + type: 'TENANT_SELECTION_REQUIRED', + selectionToken: data.access_token, + tenants: data.tenants, + }); + setStatusMessage('Select a tenant to continue.', 'info'); + return; + } + + if (!data?.access_token) { + throw new Error('Invalid login response.'); + } + + appDispatch({ + type: 'LOGIN_SUCCESS', + token: data.access_token, + tenant: data.tenant || null, + }); + setStatusMessage('Login successful.', 'success'); + preferredTenantRef.current = null; + } catch (error) { + if (cancelled) { + return; + } + const message = error?.response?.data?.error || 'Magic link login failed.'; + notifyLoginError(error, message); + appDispatch({ type: 'LOGIN_FAILURE', error: message }); + preferredTenantRef.current = null; + } finally { + if (!cancelled) { + setMagicLoginPending(false); + magicLoginPendingRef.current = false; + clearMagicParamsFromUrl(); + } + } + }; + + attemptMagicLogin(); + + return () => { + cancelled = true; + magicLoginPendingRef.current = false; + setMagicLoginPending(false); + }; + }, [ + appDispatch, + clearMagicParamsFromUrl, + magicToken, + magicUsername, + magicPreferredTenantId, + notifyLoginError, + setStatusMessage, + ]); + + useEffect(() => { + const preferredTenantId = preferredTenantRef.current; + if (!preferredTenantId) { + return; + } + if (!tenantSelection?.tenants?.length) { + return; + } + if (selectingTenantId) { + return; + } + const match = tenantSelection.tenants.find((tenant) => tenant.id === preferredTenantId); + if (!match) { + preferredTenantRef.current = null; + return; + } + handleTenantSelect(match); + preferredTenantRef.current = null; + }, [handleTenantSelect, selectingTenantId, tenantSelection]); + const handleSignup = useCallback( async (rawUsername) => { const username = typeof rawUsername === 'string' ? rawUsername.trim() : ''; @@ -265,12 +479,14 @@ const LoginRoute = () => { onCancelSelection={handleCancelSelection} selectingTenantId={selectingTenantId} onPasskeyLogin={handlePasskeyLogin} - passkeySupported={passkeySupported} - passkeyLoading={passkeyLoading} - onSignup={handleSignup} - signupSupported={signupSupported} - signupLoading={signupLoading} - /> + passkeySupported={passkeySupported} + passkeyLoading={passkeyLoading} + onSignup={handleSignup} + signupSupported={signupSupported} + signupLoading={signupLoading} + magicLoginPending={magicLoginPending} + initialUsername={magicLoginParams.username || ''} + /> ); }; diff --git a/frontend/src/login/LoginView.jsx b/frontend/src/login/LoginView.jsx index 8b05db9..fc96729 100644 --- a/frontend/src/login/LoginView.jsx +++ b/frontend/src/login/LoginView.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; const StatusBanner = ({ status }) => { if (!status) return null; @@ -17,9 +17,15 @@ const LoginView = ({ passkeyLoading = false, signupSupported = false, signupLoading = false, + magicLoginPending = false, + initialUsername = '', }) => { const hasTenantSelection = Boolean(tenantSelection?.tenants?.length); - const [username, setUsername] = useState(''); + const [username, setUsername] = useState(initialUsername); + + useEffect(() => { + setUsername(initialUsername); + }, [initialUsername]); const handlePasskeyClick = () => { if (!onPasskeyLogin) { @@ -37,7 +43,7 @@ const LoginView = ({ const handleSubmit = (event) => { event.preventDefault(); - if (!passkeySupported || passkeyLoading || !username.trim()) { + if (!passkeySupported || passkeyLoading || magicLoginPending || !username.trim()) { return; } handlePasskeyClick(); @@ -88,13 +94,14 @@ const LoginView = ({ onChange={(event) => setUsername(event.target.value)} placeholder="Username" autoComplete="username" + disabled={passkeyLoading || magicLoginPending} required /> {passkeySupported ? ( @@ -107,7 +114,7 @@ const LoginView = ({ type="button" className="login-card__signup-button" onClick={handleSignupClick} - disabled={signupLoading || !username.trim()} + disabled={signupLoading || magicLoginPending || !username.trim()} > {signupLoading ? 'Creating account…' : 'Create account with passkey'}