magic token login

This commit is contained in:
2025-10-31 15:16:30 +01:00
parent 91dd3289a9
commit 48bed9a9fe
10 changed files with 719 additions and 92 deletions
+124
View File
@@ -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"
+1
View File
@@ -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"
@@ -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
View File
@@ -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<String>,
#[arg(long = "quickwit-index")]
quickwit_index: Option<String>,
},
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<i32>,
#[arg(long = "kind", value_enum, default_value_t = MagicTokenKindArg::EmailLogin)]
kind: MagicTokenKindArg,
},
}
impl Command {
fn usage() -> &'static str {
"Usage: admin\n\
create-user <username>\n\
list-users\n\
delete-user <username>\n\
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>"
}
#[derive(Copy, Clone, Debug, ValueEnum)]
enum MagicTokenKindArg {
#[value(name = "email_login")]
EmailLogin,
#[value(name = "demo_login")]
DemoLogin,
}
fn parse_tenant_id(arg: Option<String>) -> Result<Uuid> {
let raw = arg.ok_or_else(|| anyhow!("tenant id required"))?;
Uuid::parse_str(&raw).map_err(|_| anyhow!("invalid tenant id: {}", raw))
}
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())),
impl From<MagicTokenKindArg> 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<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<()> {
let mut conn = pool.get().context("failed to get database connection")?;
+77 -1
View File
@@ -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<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 {
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<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)]
#[diesel(table_name = document_versions)]
#[diesel(belongs_to(Document))]
+109 -8
View File
@@ -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<String>,
#[serde(default)]
#[schema(nullable)]
pub magic_token: Option<String>,
#[serde(default)]
#[schema(nullable)]
pub preferred_tenant_id: Option<Uuid>,
@@ -152,10 +161,41 @@ pub struct AuthApiDoc;
),
tag = "Auth"
)]
pub async fn login(_state: State<AppState>, _payload: Json<LoginRequest>) -> AppResult<Response> {
Err(AppError::bad_request(
"password authentication is no longer supported",
))
pub async fn login(
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",
));
}
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<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(
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);
+24
View File
@@ -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<Int4>,
used_count -> Int4,
created_at -> Timestamptz,
created_by -> Nullable<Uuid>,
last_used_at -> Nullable<Timestamptz>,
}
}
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,
+223 -7
View File
@@ -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 || ''}
/>
</div>
);
};
+12 -5
View File
@@ -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 ? (
<button
type="submit"
className="login-card__passkey-button"
disabled={passkeyLoading || !username.trim()}
disabled={passkeyLoading || magicLoginPending || !username.trim()}
>
{passkeyLoading ? 'Signing in…' : 'Sign in with passkey'}
</button>
@@ -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'}
</button>