magic token login
This commit is contained in:
Generated
+124
@@ -41,6 +41,56 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.100"
|
version = "1.0.100"
|
||||||
@@ -834,6 +884,46 @@ dependencies = [
|
|||||||
"libloading",
|
"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]]
|
[[package]]
|
||||||
name = "cmake"
|
name = "cmake"
|
||||||
version = "0.1.54"
|
version = "0.1.54"
|
||||||
@@ -843,6 +933,12 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "console_error_panic_hook"
|
name = "console_error_panic_hook"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -1909,6 +2005,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
@@ -2228,6 +2330,12 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.74"
|
version = "0.10.74"
|
||||||
@@ -2313,6 +2421,7 @@ dependencies = [
|
|||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"clap",
|
||||||
"diesel",
|
"diesel",
|
||||||
"diesel_migrations",
|
"diesel_migrations",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
@@ -3781,6 +3890,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utoipa"
|
name = "utoipa"
|
||||||
version = "4.2.3"
|
version = "4.2.3"
|
||||||
@@ -4143,6 +4258,15 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ futures-util = "0.3"
|
|||||||
url = "2.5"
|
url = "2.5"
|
||||||
once_cell = "1.19"
|
once_cell = "1.19"
|
||||||
utoipa = { version = "4.2", default-features = false, features = ["chrono", "uuid", "preserve_order"] }
|
utoipa = { version = "4.2", default-features = false, features = ["chrono", "uuid", "preserve_order"] }
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE magic_tokens;
|
||||||
|
DROP TYPE magic_token_kind;
|
||||||
@@ -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);
|
||||||
+129
-71
@@ -1,9 +1,12 @@
|
|||||||
use std::env;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
use chrono::{Duration as ChronoDuration, Utc};
|
||||||
|
use clap::{Parser, Subcommand, ValueEnum};
|
||||||
use diesel::{dsl::exists, prelude::*, select};
|
use diesel::{dsl::exists, prelude::*, select};
|
||||||
|
use rand::{rngs::OsRng, RngCore};
|
||||||
use reqwest::{Client, Method, StatusCode};
|
use reqwest::{Client, Method, StatusCode};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use papercrate::{
|
use papercrate::{
|
||||||
@@ -12,18 +15,31 @@ use papercrate::{
|
|||||||
documents::search::ensure_quickwit_index,
|
documents::search::ensure_quickwit_index,
|
||||||
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT},
|
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT},
|
||||||
models::{
|
models::{
|
||||||
DocumentAsset, DocumentAssetObject, NewUser, NewUserMembership, Tenant, TenantStatus, User,
|
DocumentAsset, DocumentAssetObject, MagicToken, MagicTokenKind, NewUser, NewUserMembership,
|
||||||
|
Tenant, TenantStatus, User,
|
||||||
},
|
},
|
||||||
s3,
|
s3,
|
||||||
schema::{
|
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},
|
storage::{ObjectStorage, S3Storage, TenantStorage},
|
||||||
tenants::TenantService,
|
tenants::TenantService,
|
||||||
utils::tracing::init_tracing,
|
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 {
|
enum Command {
|
||||||
CreateUser {
|
CreateUser {
|
||||||
username: String,
|
username: String,
|
||||||
@@ -34,7 +50,9 @@ enum Command {
|
|||||||
},
|
},
|
||||||
CreateTenant {
|
CreateTenant {
|
||||||
name: String,
|
name: String,
|
||||||
|
#[arg(long = "storage-root")]
|
||||||
storage_root: Option<String>,
|
storage_root: Option<String>,
|
||||||
|
#[arg(long = "quickwit-index")]
|
||||||
quickwit_index: Option<String>,
|
quickwit_index: Option<String>,
|
||||||
},
|
},
|
||||||
DeleteTenant {
|
DeleteTenant {
|
||||||
@@ -52,71 +70,43 @@ enum Command {
|
|||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
},
|
},
|
||||||
ListTenants,
|
ListTenants,
|
||||||
DeleteAssets(Uuid),
|
DeleteAssets {
|
||||||
QuickwitCreate(Uuid),
|
tenant_id: Uuid,
|
||||||
QuickwitDelete(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<i32>,
|
||||||
|
#[arg(long = "kind", value_enum, default_value_t = MagicTokenKindArg::EmailLogin)]
|
||||||
|
kind: MagicTokenKindArg,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Command {
|
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||||
fn usage() -> &'static str {
|
enum MagicTokenKindArg {
|
||||||
"Usage: admin\n\
|
#[value(name = "email_login")]
|
||||||
create-user <username>\n\
|
EmailLogin,
|
||||||
list-users\n\
|
#[value(name = "demo_login")]
|
||||||
delete-user <username>\n\
|
DemoLogin,
|
||||||
create-tenant <name> [storage_root] [quickwit_index]\n\
|
}
|
||||||
delete-tenant <tenant-id>\n\
|
|
||||||
add-user-to-tenant <username> <tenant-id>\n\
|
|
||||||
remove-user-from-tenant <username> <tenant-id>\n\
|
|
||||||
reanalyze-documents <tenant-id>\n\
|
|
||||||
list-tenants\n\
|
|
||||||
delete-assets <tenant-id>\n\
|
|
||||||
quickwit-create-index <tenant-id>\n\
|
|
||||||
quickwit-delete-index <tenant-id>"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_tenant_id(arg: Option<String>) -> Result<Uuid> {
|
impl From<MagicTokenKindArg> for MagicTokenKind {
|
||||||
let raw = arg.ok_or_else(|| anyhow!("tenant id required"))?;
|
fn from(value: MagicTokenKindArg) -> Self {
|
||||||
Uuid::parse_str(&raw).map_err(|_| anyhow!("invalid tenant id: {}", raw))
|
match value {
|
||||||
}
|
MagicTokenKindArg::EmailLogin => MagicTokenKind::EmailLogin,
|
||||||
|
MagicTokenKindArg::DemoLogin => MagicTokenKind::DemoLogin,
|
||||||
fn parse() -> Result<Self> {
|
|
||||||
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())),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,11 +114,11 @@ impl Command {
|
|||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
init_tracing("info");
|
init_tracing("info");
|
||||||
let command = Command::parse()?;
|
let cli = Cli::parse();
|
||||||
let config = AppConfig::load_and_log("admin")?;
|
let config = AppConfig::load_and_log("admin")?;
|
||||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
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::CreateUser { username } => create_user(&pool, &username)?,
|
||||||
Command::ListUsers => list_users(&pool)?,
|
Command::ListUsers => list_users(&pool)?,
|
||||||
Command::DeleteUser { username } => delete_user(&pool, &username)?,
|
Command::DeleteUser { username } => delete_user(&pool, &username)?,
|
||||||
@@ -148,15 +138,23 @@ async fn main() -> Result<()> {
|
|||||||
} => remove_user_from_tenant(&pool, &username, tenant_id)?,
|
} => remove_user_from_tenant(&pool, &username, tenant_id)?,
|
||||||
Command::ReanalyzeDocuments { tenant_id } => reanalyze_documents(&pool, tenant_id)?,
|
Command::ReanalyzeDocuments { tenant_id } => reanalyze_documents(&pool, tenant_id)?,
|
||||||
Command::ListTenants => list_tenants(&pool)?,
|
Command::ListTenants => list_tenants(&pool)?,
|
||||||
Command::DeleteAssets(tenant_id) => {
|
Command::DeleteAssets { tenant_id } => {
|
||||||
delete_assets_for_tenant(&config, &pool, tenant_id).await?
|
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?
|
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?
|
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(())
|
Ok(())
|
||||||
@@ -268,6 +266,66 @@ fn create_tenant(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_magic_token(
|
||||||
|
pool: &PgPool,
|
||||||
|
username: &str,
|
||||||
|
ttl_minutes: i64,
|
||||||
|
max_uses: Option<i32>,
|
||||||
|
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<()> {
|
fn delete_tenant(pool: &PgPool, tenant_id: Uuid) -> Result<()> {
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
|
|
||||||
|
|||||||
+77
-1
@@ -10,7 +10,9 @@ use std::io::Write;
|
|||||||
use std::str;
|
use std::str;
|
||||||
use uuid::Uuid;
|
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::*;
|
use crate::schema::*;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
@@ -43,6 +45,64 @@ pub enum TenantStatus {
|
|||||||
Error,
|
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<MagicTokenKindSql, Pg> 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<MagicTokenKindSql, Pg> for MagicTokenKind {
|
||||||
|
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
|
||||||
|
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<Self, Self::Err> {
|
||||||
|
match value {
|
||||||
|
"email_login" => Ok(MagicTokenKind::EmailLogin),
|
||||||
|
"demo_login" => Ok(MagicTokenKind::DemoLogin),
|
||||||
|
_ => Err("unsupported magic token kind"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TenantStatus {
|
impl TenantStatus {
|
||||||
pub fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
@@ -265,6 +325,22 @@ pub struct NewDocument {
|
|||||||
pub tenant_id: Uuid,
|
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<i32>,
|
||||||
|
pub used_count: i32,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub created_by: Option<Uuid>,
|
||||||
|
pub last_used_at: Option<NaiveDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
#[diesel(table_name = document_versions)]
|
#[diesel(table_name = document_versions)]
|
||||||
#[diesel(belongs_to(Document))]
|
#[diesel(belongs_to(Document))]
|
||||||
|
|||||||
+108
-7
@@ -25,10 +25,13 @@ use crate::{
|
|||||||
AuthenticatedUser,
|
AuthenticatedUser,
|
||||||
},
|
},
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership},
|
models::{
|
||||||
|
MagicToken, MagicTokenKind, NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus,
|
||||||
|
User, UserMembership,
|
||||||
|
},
|
||||||
schema::{
|
schema::{
|
||||||
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
|
magic_tokens::dsl as magic_dsl, refresh_tokens, tenants::dsl as tenant_dsl,
|
||||||
user_passkeys::dsl as passkey_dsl, users::dsl,
|
user_memberships::dsl as memberships_dsl, user_passkeys::dsl as passkey_dsl, users::dsl,
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
@@ -40,8 +43,14 @@ const REFRESH_COOKIE_NAME: &str = "refresh_token";
|
|||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub struct LoginRequest {
|
pub struct LoginRequest {
|
||||||
|
#[serde(default)]
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
#[serde(default)]
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub password: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub magic_token: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub preferred_tenant_id: Option<Uuid>,
|
pub preferred_tenant_id: Option<Uuid>,
|
||||||
@@ -152,10 +161,41 @@ pub struct AuthApiDoc;
|
|||||||
),
|
),
|
||||||
tag = "Auth"
|
tag = "Auth"
|
||||||
)]
|
)]
|
||||||
pub async fn login(_state: State<AppState>, _payload: Json<LoginRequest>) -> AppResult<Response> {
|
pub async fn login(
|
||||||
Err(AppError::bad_request(
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<LoginRequest>,
|
||||||
|
) -> AppResult<Response> {
|
||||||
|
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",
|
"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(
|
#[utoipa::path(
|
||||||
@@ -644,6 +684,63 @@ fn complete_login(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn magic_token_login(
|
||||||
|
state: &AppState,
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
token_value: &str,
|
||||||
|
username_hint: Option<&str>,
|
||||||
|
preferred_tenant_id: Option<Uuid>,
|
||||||
|
) -> AppResult<Response> {
|
||||||
|
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::<Response, AppError, _>(|conn| {
|
||||||
|
let magic = magic_dsl::magic_tokens
|
||||||
|
.filter(magic_dsl::token_hash.eq(&token_hash))
|
||||||
|
.filter(magic_dsl::expires_at.gt(now_naive))
|
||||||
|
.first::<MagicToken>(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(
|
fn issue_session(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
@@ -704,6 +801,10 @@ fn hash_refresh_token(token: &str) -> String {
|
|||||||
hex::encode(hasher.finalize())
|
hex::encode(hasher.finalize())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn hash_magic_token(token: &str) -> String {
|
||||||
|
hash_refresh_token(token)
|
||||||
|
}
|
||||||
|
|
||||||
fn generate_refresh_token() -> String {
|
fn generate_refresh_token() -> String {
|
||||||
let mut bytes = [0u8; 32];
|
let mut bytes = [0u8; 32];
|
||||||
OsRng.fill_bytes(&mut bytes);
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
// @generated automatically by Diesel CLI.
|
// @generated automatically by Diesel CLI.
|
||||||
|
|
||||||
pub mod sql_types {
|
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)]
|
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
||||||
#[diesel(postgres_type(name = "tenant_status"))]
|
#[diesel(postgres_type(name = "tenant_status"))]
|
||||||
pub struct TenantStatus;
|
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<Int4>,
|
||||||
|
used_count -> Int4,
|
||||||
|
created_at -> Timestamptz,
|
||||||
|
created_by -> Nullable<Uuid>,
|
||||||
|
last_used_at -> Nullable<Timestamptz>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
refresh_tokens (id) {
|
refresh_tokens (id) {
|
||||||
id -> Uuid,
|
id -> Uuid,
|
||||||
@@ -275,6 +298,7 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||||||
documents,
|
documents,
|
||||||
folders,
|
folders,
|
||||||
jobs,
|
jobs,
|
||||||
|
magic_tokens,
|
||||||
refresh_tokens,
|
refresh_tokens,
|
||||||
tags,
|
tags,
|
||||||
tenants,
|
tenants,
|
||||||
|
|||||||
@@ -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 { Navigate, useLocation } from 'react-router-dom';
|
||||||
import LoginView from '../login/LoginView';
|
import LoginView from '../login/LoginView';
|
||||||
import useApiError from '../hooks/useApiError';
|
import useApiError from '../hooks/useApiError';
|
||||||
@@ -21,6 +21,60 @@ const LoginRoute = () => {
|
|||||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||||
const signupSupported = passkeySupported;
|
const signupSupported = passkeySupported;
|
||||||
const [signupLoading, setSignupLoading] = useState(false);
|
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') => {
|
const setStatusMessage = useCallback((message, variant = 'info') => {
|
||||||
setStatus(message ? { message, variant } : null);
|
setStatus(message ? { message, variant } : null);
|
||||||
@@ -41,6 +95,50 @@ const LoginRoute = () => {
|
|||||||
[reportLoginError],
|
[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(
|
const handleTenantSelect = useCallback(
|
||||||
async (tenant) => {
|
async (tenant) => {
|
||||||
if (!tenantSelection?.selectionToken || !tenant?.id) {
|
if (!tenantSelection?.selectionToken || !tenant?.id) {
|
||||||
@@ -165,6 +263,122 @@ const LoginRoute = () => {
|
|||||||
[appDispatch, notifyLoginError, passkeySupported, setStatusMessage],
|
[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(
|
const handleSignup = useCallback(
|
||||||
async (rawUsername) => {
|
async (rawUsername) => {
|
||||||
const username = typeof rawUsername === 'string' ? rawUsername.trim() : '';
|
const username = typeof rawUsername === 'string' ? rawUsername.trim() : '';
|
||||||
@@ -270,6 +484,8 @@ const LoginRoute = () => {
|
|||||||
onSignup={handleSignup}
|
onSignup={handleSignup}
|
||||||
signupSupported={signupSupported}
|
signupSupported={signupSupported}
|
||||||
signupLoading={signupLoading}
|
signupLoading={signupLoading}
|
||||||
|
magicLoginPending={magicLoginPending}
|
||||||
|
initialUsername={magicLoginParams.username || ''}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
const StatusBanner = ({ status }) => {
|
const StatusBanner = ({ status }) => {
|
||||||
if (!status) return null;
|
if (!status) return null;
|
||||||
@@ -17,9 +17,15 @@ const LoginView = ({
|
|||||||
passkeyLoading = false,
|
passkeyLoading = false,
|
||||||
signupSupported = false,
|
signupSupported = false,
|
||||||
signupLoading = false,
|
signupLoading = false,
|
||||||
|
magicLoginPending = false,
|
||||||
|
initialUsername = '',
|
||||||
}) => {
|
}) => {
|
||||||
const hasTenantSelection = Boolean(tenantSelection?.tenants?.length);
|
const hasTenantSelection = Boolean(tenantSelection?.tenants?.length);
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState(initialUsername);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUsername(initialUsername);
|
||||||
|
}, [initialUsername]);
|
||||||
|
|
||||||
const handlePasskeyClick = () => {
|
const handlePasskeyClick = () => {
|
||||||
if (!onPasskeyLogin) {
|
if (!onPasskeyLogin) {
|
||||||
@@ -37,7 +43,7 @@ const LoginView = ({
|
|||||||
|
|
||||||
const handleSubmit = (event) => {
|
const handleSubmit = (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!passkeySupported || passkeyLoading || !username.trim()) {
|
if (!passkeySupported || passkeyLoading || magicLoginPending || !username.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handlePasskeyClick();
|
handlePasskeyClick();
|
||||||
@@ -88,13 +94,14 @@ const LoginView = ({
|
|||||||
onChange={(event) => setUsername(event.target.value)}
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
|
disabled={passkeyLoading || magicLoginPending}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{passkeySupported ? (
|
{passkeySupported ? (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="login-card__passkey-button"
|
className="login-card__passkey-button"
|
||||||
disabled={passkeyLoading || !username.trim()}
|
disabled={passkeyLoading || magicLoginPending || !username.trim()}
|
||||||
>
|
>
|
||||||
{passkeyLoading ? 'Signing in…' : 'Sign in with passkey'}
|
{passkeyLoading ? 'Signing in…' : 'Sign in with passkey'}
|
||||||
</button>
|
</button>
|
||||||
@@ -107,7 +114,7 @@ const LoginView = ({
|
|||||||
type="button"
|
type="button"
|
||||||
className="login-card__signup-button"
|
className="login-card__signup-button"
|
||||||
onClick={handleSignupClick}
|
onClick={handleSignupClick}
|
||||||
disabled={signupLoading || !username.trim()}
|
disabled={signupLoading || magicLoginPending || !username.trim()}
|
||||||
>
|
>
|
||||||
{signupLoading ? 'Creating account…' : 'Create account with passkey'}
|
{signupLoading ? 'Creating account…' : 'Create account with passkey'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user