Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c65a3d2c40 | ||
|
|
9fe97f2b8a | ||
|
|
e5d44c13b4 | ||
|
|
f89c6bb259 | ||
|
|
b9966f82a0 | ||
|
|
84f4cd05f4 | ||
|
|
4d14d5a3d4 | ||
|
|
d72792fe0a | ||
|
|
48da6b9c67 | ||
|
|
e3f8590ba2 | ||
|
|
384483b14c | ||
|
|
eb3eae3fac | ||
|
|
dfa2c1c0e2 | ||
|
|
22f4d42c19 | ||
|
|
4a116e013c | ||
|
|
5fe0da1a65 | ||
|
|
7a6ac5f0be | ||
|
|
15f79d3d8b | ||
|
|
05914b147d | ||
|
|
abc0116945 | ||
|
|
b6bcb72165 | ||
|
|
ca30b20873 | ||
|
|
ec6e7f04f2 | ||
|
|
1cc0aafc1a | ||
|
|
abe3fc777c | ||
|
|
6354f7e189 | ||
|
|
3731b90f6c | ||
|
|
433485bcf0 | ||
|
|
f1f2dee701 | ||
|
|
2a17f2d333 | ||
|
|
788858f0c1 | ||
|
|
ee8955574f | ||
|
|
ac27a9ab36 | ||
|
|
2de3dc30bf | ||
|
|
4d30c4c58f |
Generated
+273
@@ -59,6 +59,45 @@ dependencies = [
|
|||||||
"password-hash",
|
"password-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asn1-rs"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
|
||||||
|
dependencies = [
|
||||||
|
"asn1-rs-derive",
|
||||||
|
"asn1-rs-impl",
|
||||||
|
"displaydoc",
|
||||||
|
"nom",
|
||||||
|
"num-traits",
|
||||||
|
"rusticata-macros",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asn1-rs-derive"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.106",
|
||||||
|
"synstructure",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asn1-rs-impl"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.106",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@@ -627,6 +666,8 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde-aux",
|
"serde-aux",
|
||||||
|
"serde_bytes",
|
||||||
|
"serde_cbor_2",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -639,6 +680,8 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"webauthn-rs",
|
||||||
|
"webauthn-rs-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -690,6 +733,17 @@ version = "1.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
|
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64urlsafedata"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "215ee31f8a88f588c349ce2d20108b2ed96089b96b9c2b03775dc35dd72938e8"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.21.7",
|
||||||
|
"pastey",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bindgen"
|
name = "bindgen"
|
||||||
version = "0.72.1"
|
version = "0.72.1"
|
||||||
@@ -939,6 +993,12 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crunchy"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-bigint"
|
name = "crypto-bigint"
|
||||||
version = "0.4.9"
|
version = "0.4.9"
|
||||||
@@ -1006,6 +1066,12 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -1016,6 +1082,20 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "der-parser"
|
||||||
|
version = "9.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
|
||||||
|
dependencies = [
|
||||||
|
"asn1-rs",
|
||||||
|
"displaydoc",
|
||||||
|
"nom",
|
||||||
|
"num-bigint",
|
||||||
|
"num-traits",
|
||||||
|
"rusticata-macros",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.4"
|
version = "0.5.4"
|
||||||
@@ -1255,6 +1335,21 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -1429,6 +1524,17 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "half"
|
||||||
|
version = "2.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"crunchy",
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -2159,18 +2265,65 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oid-registry"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
|
||||||
|
dependencies = [
|
||||||
|
"asn1-rs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.3"
|
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 = "openssl"
|
||||||
|
version = "0.10.74"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cfg-if",
|
||||||
|
"foreign-types",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"openssl-macros",
|
||||||
|
"openssl-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.106",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-sys"
|
||||||
|
version = "0.9.110"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ordered-float"
|
name = "ordered-float"
|
||||||
version = "2.10.1"
|
version = "2.10.1"
|
||||||
@@ -2231,6 +2384,12 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pastey"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfium-render"
|
name = "pdfium-render"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@@ -2707,6 +2866,15 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusticata-macros"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||||
|
dependencies = [
|
||||||
|
"nom",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -2946,6 +3114,26 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_bytes"
|
||||||
|
version = "0.11.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_cbor_2"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c"
|
||||||
|
dependencies = [
|
||||||
|
"half",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_core"
|
name = "serde_core"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
@@ -3801,6 +3989,74 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webauthn-attestation-ca"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f77a2892ec44032e6c48dad9aad1b05fada09c346ada11d8d32db119b4b4f205"
|
||||||
|
dependencies = [
|
||||||
|
"base64urlsafedata",
|
||||||
|
"openssl",
|
||||||
|
"openssl-sys",
|
||||||
|
"serde",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webauthn-rs"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eb7c3a2f9c8bddd524e47bbd427bcf3a28aa074de55d74470b42a91a41937b8e"
|
||||||
|
dependencies = [
|
||||||
|
"base64urlsafedata",
|
||||||
|
"serde",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
"uuid",
|
||||||
|
"webauthn-rs-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webauthn-rs-core"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "19f1d80f3146382529fe70a3ab5d0feb2413a015204ed7843f9377cd39357fc4"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.21.7",
|
||||||
|
"base64urlsafedata",
|
||||||
|
"der-parser",
|
||||||
|
"hex",
|
||||||
|
"nom",
|
||||||
|
"openssl",
|
||||||
|
"openssl-sys",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"rand_chacha 0.3.1",
|
||||||
|
"serde",
|
||||||
|
"serde_cbor_2",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
"uuid",
|
||||||
|
"webauthn-attestation-ca",
|
||||||
|
"webauthn-rs-proto",
|
||||||
|
"x509-parser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webauthn-rs-proto"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e786894f89facb9aaf1c5f6559670236723c98382e045521c76f3d5ca5047bd"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.21.7",
|
||||||
|
"base64urlsafedata",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -4043,6 +4299,23 @@ version = "0.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
|
checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "x509-parser"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
|
||||||
|
dependencies = [
|
||||||
|
"asn1-rs",
|
||||||
|
"data-encoding",
|
||||||
|
"der-parser",
|
||||||
|
"lazy_static",
|
||||||
|
"nom",
|
||||||
|
"oid-registry",
|
||||||
|
"rusticata-macros",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xmlparser"
|
name = "xmlparser"
|
||||||
version = "0.13.6"
|
version = "0.13.6"
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ anyhow = "1.0"
|
|||||||
# Authentication & security
|
# Authentication & security
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
|
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
||||||
|
serde_bytes = "0.11"
|
||||||
|
serde_cbor_2 = "0.13"
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
@@ -64,3 +67,4 @@ rand = "0.8"
|
|||||||
once_cell = "1.19"
|
once_cell = "1.19"
|
||||||
hyper = "1.2"
|
hyper = "1.2"
|
||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
|
webauthn-rs-core = "0.5"
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE tenants
|
||||||
|
DROP COLUMN IF EXISTS status,
|
||||||
|
DROP COLUMN IF EXISTS created_by;
|
||||||
|
|
||||||
|
DROP TYPE IF EXISTS tenant_status;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
ALTER TABLE tenants
|
||||||
|
DROP COLUMN IF EXISTS status;
|
||||||
|
|
||||||
|
ALTER TABLE tenants
|
||||||
|
DROP COLUMN IF EXISTS created_by;
|
||||||
|
|
||||||
|
DROP TYPE IF EXISTS tenant_status;
|
||||||
|
|
||||||
|
CREATE TYPE tenant_status AS ENUM ('creating', 'active', 'suspended', 'deleting', 'error');
|
||||||
|
|
||||||
|
ALTER TABLE tenants
|
||||||
|
ADD COLUMN status tenant_status,
|
||||||
|
ADD COLUMN created_by UUID;
|
||||||
|
|
||||||
|
UPDATE tenants
|
||||||
|
SET status = 'active';
|
||||||
|
|
||||||
|
ALTER TABLE tenants
|
||||||
|
ALTER COLUMN status SET NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS user_passkeys;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE user_passkeys (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
credential_id BYTEA NOT NULL UNIQUE,
|
||||||
|
public_key BYTEA NOT NULL,
|
||||||
|
credential JSONB NOT NULL,
|
||||||
|
sign_count BIGINT NOT NULL,
|
||||||
|
transports TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
aaguid UUID,
|
||||||
|
nickname TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
revoked_by UUID,
|
||||||
|
revoked_reason TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX user_passkeys_user_id_idx ON user_passkeys (user_id);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS webauthn_challenges;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE webauthn_challenges (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
purpose TEXT NOT NULL,
|
||||||
|
challenge BYTEA NOT NULL,
|
||||||
|
state BYTEA NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
CONSTRAINT webauthn_challenges_purpose_check CHECK (purpose IN ('registration', 'authentication'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX webauthn_challenges_user_id_idx ON webauthn_challenges (user_id);
|
||||||
|
CREATE INDEX webauthn_challenges_expires_at_idx ON webauthn_challenges (expires_at);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE user_memberships
|
||||||
|
ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE user_memberships
|
||||||
|
DROP COLUMN role;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE tenants
|
||||||
|
RENAME COLUMN name TO slug;
|
||||||
|
|
||||||
|
ALTER TABLE tenants
|
||||||
|
RENAME CONSTRAINT tenants_name_key TO tenants_slug_key;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE tenants
|
||||||
|
RENAME COLUMN slug TO name;
|
||||||
|
|
||||||
|
ALTER TABLE tenants
|
||||||
|
RENAME CONSTRAINT tenants_slug_key TO tenants_name_key;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE tenants
|
||||||
|
ADD CONSTRAINT tenants_name_key UNIQUE (name);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE tenants
|
||||||
|
DROP CONSTRAINT IF EXISTS tenants_name_key;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN password_hash VARCHAR(255) NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- Optional: remove the default if you need to reintroduce passwords later
|
||||||
|
ALTER TABLE users
|
||||||
|
ALTER COLUMN password_hash DROP DEFAULT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN password_hash;
|
||||||
@@ -17,6 +17,8 @@ pub struct JwtService {
|
|||||||
download_expiry: Duration,
|
download_expiry: Duration,
|
||||||
selector_audience: String,
|
selector_audience: String,
|
||||||
selector_expiry: Duration,
|
selector_expiry: Duration,
|
||||||
|
signup_audience: String,
|
||||||
|
signup_expiry: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JwtService {
|
impl JwtService {
|
||||||
@@ -31,6 +33,8 @@ impl JwtService {
|
|||||||
download_expiry: Duration::minutes(config.download_token_expiry_minutes),
|
download_expiry: Duration::minutes(config.download_token_expiry_minutes),
|
||||||
selector_audience: format!("{}:tenant-selector", config.jwt_audience),
|
selector_audience: format!("{}:tenant-selector", config.jwt_audience),
|
||||||
selector_expiry: Duration::minutes(15),
|
selector_expiry: Duration::minutes(15),
|
||||||
|
signup_audience: format!("{}:signup", config.jwt_audience),
|
||||||
|
signup_expiry: Duration::minutes(15),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +112,35 @@ impl JwtService {
|
|||||||
let data = decode::<TenantSelectionClaims>(token, &self.decoding, &validation)?;
|
let data = decode::<TenantSelectionClaims>(token, &self.decoding, &validation)?;
|
||||||
Ok(data.claims)
|
Ok(data.claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn generate_signup_token(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
challenge_id: Uuid,
|
||||||
|
username: String,
|
||||||
|
) -> Result<String> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let exp = now + self.signup_expiry;
|
||||||
|
let claims = SignupClaims {
|
||||||
|
sub: user_id,
|
||||||
|
challenge_id,
|
||||||
|
username,
|
||||||
|
iss: self.issuer.clone(),
|
||||||
|
aud: self.signup_audience.clone(),
|
||||||
|
iat: now.timestamp() as usize,
|
||||||
|
exp: exp.timestamp() as usize,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(encode(&Header::default(), &claims, &self.encoding)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_signup_token(&self, token: &str) -> Result<SignupClaims> {
|
||||||
|
let mut validation = Validation::default();
|
||||||
|
validation.set_audience(&[self.signup_audience.clone()]);
|
||||||
|
validation.set_issuer(&[self.issuer.clone()]);
|
||||||
|
let data = decode::<SignupClaims>(token, &self.decoding, &validation)?;
|
||||||
|
Ok(data.claims)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -140,3 +173,14 @@ pub struct TenantSelectionClaims {
|
|||||||
pub iat: usize,
|
pub iat: usize,
|
||||||
pub exp: usize,
|
pub exp: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SignupClaims {
|
||||||
|
pub sub: Uuid,
|
||||||
|
pub challenge_id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub iss: String,
|
||||||
|
pub aud: String,
|
||||||
|
pub iat: usize,
|
||||||
|
pub exp: usize,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod jwt;
|
pub mod jwt;
|
||||||
|
pub mod passkeys;
|
||||||
pub mod password;
|
pub mod password;
|
||||||
pub mod webdav_tokens;
|
pub mod webdav_tokens;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,670 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc};
|
||||||
|
use diesel::{dsl::count_star, prelude::*};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use webauthn_rs::prelude::{Credential, *};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::AppConfig,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::{NewUserPasskey, NewWebauthnChallenge, User, UserPasskey, WebauthnChallenge},
|
||||||
|
schema::{user_passkeys::dsl as passkey_dsl, webauthn_challenges::dsl as challenge_dsl},
|
||||||
|
};
|
||||||
|
|
||||||
|
const PURPOSE_REGISTRATION: &str = "registration";
|
||||||
|
const PURPOSE_AUTHENTICATION: &str = "authentication";
|
||||||
|
const DEFAULT_CHALLENGE_TTL_MINUTES: i64 = 10;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PasskeyService {
|
||||||
|
webauthn: Arc<Webauthn>,
|
||||||
|
challenge_ttl: ChronoDuration,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PreparedPasskey {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub credential_id: Vec<u8>,
|
||||||
|
pub public_key: Vec<u8>,
|
||||||
|
pub credential: serde_json::Value,
|
||||||
|
pub sign_count: i64,
|
||||||
|
pub transports: Vec<Option<String>>,
|
||||||
|
pub aaguid: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PreparedPasskey {
|
||||||
|
pub fn into_new_user_passkey(self, user_id: Uuid, nickname: Option<String>) -> NewUserPasskey {
|
||||||
|
NewUserPasskey {
|
||||||
|
id: self.id,
|
||||||
|
user_id,
|
||||||
|
credential_id: self.credential_id,
|
||||||
|
public_key: self.public_key,
|
||||||
|
credential: self.credential,
|
||||||
|
sign_count: self.sign_count,
|
||||||
|
transports: self.transports,
|
||||||
|
aaguid: self.aaguid,
|
||||||
|
nickname,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RegistrationChallengeResponse {
|
||||||
|
pub challenge_id: Uuid,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub challenge: CreationChallengeResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AuthenticationChallengeResponse {
|
||||||
|
pub challenge_id: Uuid,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub challenge: RequestChallengeResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PasskeySummary {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub nickname: Option<String>,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub last_used_at: Option<NaiveDateTime>,
|
||||||
|
pub transports: Vec<String>,
|
||||||
|
pub revoked_at: Option<NaiveDateTime>,
|
||||||
|
pub revoked_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PasskeyService {
|
||||||
|
pub fn try_new(config: &AppConfig) -> Result<Option<Self>> {
|
||||||
|
let rp_id = match config.webauthn_rp_id.as_deref().map(str::trim) {
|
||||||
|
Some(rp_id) if !rp_id.is_empty() => rp_id,
|
||||||
|
_ => return Ok(None),
|
||||||
|
};
|
||||||
|
let rp_origin = match config.webauthn_origin.as_ref().map(|s| s.trim()) {
|
||||||
|
Some(origin) if !origin.is_empty() => origin,
|
||||||
|
_ => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let origin = Url::parse(rp_origin).context("invalid webauthn_origin")?;
|
||||||
|
|
||||||
|
let builder = WebauthnBuilder::new(rp_id, &origin)
|
||||||
|
.context("failed to initialise WebAuthn builder")?
|
||||||
|
.rp_name(&config.webauthn_rp_name)
|
||||||
|
.allow_subdomains(false)
|
||||||
|
.allow_any_port(false);
|
||||||
|
|
||||||
|
let webauthn = builder
|
||||||
|
.build()
|
||||||
|
.context("failed to build WebAuthn instance")?;
|
||||||
|
|
||||||
|
Ok(Some(Self {
|
||||||
|
webauthn: Arc::new(webauthn),
|
||||||
|
challenge_ttl: ChronoDuration::minutes(DEFAULT_CHALLENGE_TTL_MINUTES),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prune_expired(&self, conn: &mut PgConnection) {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let _ = diesel::delete(
|
||||||
|
challenge_dsl::webauthn_challenges.filter(challenge_dsl::expires_at.le(now)),
|
||||||
|
)
|
||||||
|
.execute(conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_registration(
|
||||||
|
&self,
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
user: &User,
|
||||||
|
) -> AppResult<RegistrationChallengeResponse> {
|
||||||
|
self.prune_expired(conn);
|
||||||
|
|
||||||
|
let existing: Vec<UserPasskey> = passkey_dsl::user_passkeys
|
||||||
|
.filter(passkey_dsl::user_id.eq(user.id))
|
||||||
|
.filter(passkey_dsl::revoked_at.is_null())
|
||||||
|
.load(conn)?;
|
||||||
|
|
||||||
|
let exclude = if existing.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(
|
||||||
|
existing
|
||||||
|
.iter()
|
||||||
|
.map(|pk| CredentialID::from(pk.credential_id.clone()))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (challenge, state) = self
|
||||||
|
.webauthn
|
||||||
|
.start_passkey_registration(user.id, &user.username, &user.username, exclude)
|
||||||
|
.map_err(|err| {
|
||||||
|
tracing::error!(error = %err, "failed to start passkey registration");
|
||||||
|
AppError::internal("failed to start passkey registration")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let challenge_id = Uuid::new_v4();
|
||||||
|
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
|
||||||
|
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
|
||||||
|
let state_bytes = serde_json::to_vec(&state)
|
||||||
|
.context("failed to encode passkey registration state")
|
||||||
|
.map_err(AppError::internal)?;
|
||||||
|
|
||||||
|
let record = NewWebauthnChallenge {
|
||||||
|
id: challenge_id,
|
||||||
|
user_id: Some(user.id),
|
||||||
|
purpose: PURPOSE_REGISTRATION.to_string(),
|
||||||
|
challenge: challenge_bytes,
|
||||||
|
state: state_bytes,
|
||||||
|
expires_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::insert_into(challenge_dsl::webauthn_challenges)
|
||||||
|
.values(&record)
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
Ok(RegistrationChallengeResponse {
|
||||||
|
challenge_id,
|
||||||
|
challenge,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_signup_registration(
|
||||||
|
&self,
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
user_id: Uuid,
|
||||||
|
username: &str,
|
||||||
|
) -> AppResult<RegistrationChallengeResponse> {
|
||||||
|
self.prune_expired(conn);
|
||||||
|
|
||||||
|
let (challenge, state) = self
|
||||||
|
.webauthn
|
||||||
|
.start_passkey_registration(user_id, username, username, None)
|
||||||
|
.map_err(|err| {
|
||||||
|
tracing::error!(error = %err, "failed to start passkey registration");
|
||||||
|
AppError::internal("failed to start passkey registration")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let challenge_id = Uuid::new_v4();
|
||||||
|
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
|
||||||
|
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
|
||||||
|
let state_bytes = serde_json::to_vec(&state)
|
||||||
|
.context("failed to encode passkey registration state")
|
||||||
|
.map_err(AppError::internal)?;
|
||||||
|
|
||||||
|
let record = NewWebauthnChallenge {
|
||||||
|
id: challenge_id,
|
||||||
|
user_id: None,
|
||||||
|
purpose: PURPOSE_REGISTRATION.to_string(),
|
||||||
|
challenge: challenge_bytes,
|
||||||
|
state: state_bytes,
|
||||||
|
expires_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::insert_into(challenge_dsl::webauthn_challenges)
|
||||||
|
.values(&record)
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
Ok(RegistrationChallengeResponse {
|
||||||
|
challenge_id,
|
||||||
|
challenge,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finish_registration(
|
||||||
|
&self,
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
user: &User,
|
||||||
|
challenge_id: Uuid,
|
||||||
|
credential: RegisterPublicKeyCredential,
|
||||||
|
nickname: Option<String>,
|
||||||
|
) -> AppResult<UserPasskey> {
|
||||||
|
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
|
||||||
|
.find(challenge_id)
|
||||||
|
.first(conn)
|
||||||
|
.map_err(|err| {
|
||||||
|
if matches!(err, diesel::result::Error::NotFound) {
|
||||||
|
AppError::bad_request("challenge not found")
|
||||||
|
} else {
|
||||||
|
AppError::from(err)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if record.purpose != PURPOSE_REGISTRATION {
|
||||||
|
return Err(AppError::bad_request("challenge is not for registration"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.user_id != Some(user.id) {
|
||||||
|
return Err(AppError::unauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.expires_at < Utc::now().naive_utc() {
|
||||||
|
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
||||||
|
return Err(AppError::bad_request("challenge expired"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let state: PasskeyRegistration = serde_json::from_slice(&record.state)
|
||||||
|
.context("failed to decode registration state")
|
||||||
|
.map_err(AppError::internal)?;
|
||||||
|
|
||||||
|
let passkey = self
|
||||||
|
.webauthn
|
||||||
|
.finish_passkey_registration(&credential, &state)
|
||||||
|
.map_err(|err| {
|
||||||
|
tracing::warn!(error = %err, "passkey registration validation failed");
|
||||||
|
AppError::bad_request("invalid passkey attestation")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let credential_struct: Credential = passkey.clone().into();
|
||||||
|
let credential_id_vec: Vec<u8> = credential_struct.cred_id.clone().into();
|
||||||
|
|
||||||
|
let duplicate = passkey_dsl::user_passkeys
|
||||||
|
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
|
||||||
|
.first::<UserPasskey>(conn)
|
||||||
|
.optional()?;
|
||||||
|
if duplicate.is_some() {
|
||||||
|
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
||||||
|
return Err(AppError::conflict("credential already registered"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred)
|
||||||
|
.context("failed to encode credential public key")
|
||||||
|
.map_err(AppError::internal)?;
|
||||||
|
|
||||||
|
let transports: Vec<Option<String>> = credential_struct
|
||||||
|
.transports
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|transport| Some(transport.as_ref().to_string()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let aaguid = match credential_struct.attestation.metadata {
|
||||||
|
AttestationMetadata::Packed { aaguid } | AttestationMetadata::Tpm { aaguid, .. } => {
|
||||||
|
Some(aaguid)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let credential_json = serde_json::to_value(&passkey)
|
||||||
|
.context("failed to serialise passkey")
|
||||||
|
.map_err(AppError::internal)?;
|
||||||
|
|
||||||
|
let new_passkey = NewUserPasskey {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
user_id: user.id,
|
||||||
|
credential_id: credential_id_vec,
|
||||||
|
public_key: public_key_bytes,
|
||||||
|
credential: credential_json,
|
||||||
|
sign_count: credential_struct.counter as i64,
|
||||||
|
transports,
|
||||||
|
aaguid,
|
||||||
|
nickname,
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::insert_into(passkey_dsl::user_passkeys)
|
||||||
|
.values(&new_passkey)
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
||||||
|
|
||||||
|
let created: UserPasskey = passkey_dsl::user_passkeys
|
||||||
|
.find(new_passkey.id)
|
||||||
|
.select(UserPasskey::as_select())
|
||||||
|
.first(conn)?;
|
||||||
|
|
||||||
|
Ok(created)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_authentication(
|
||||||
|
&self,
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
user: &User,
|
||||||
|
) -> AppResult<AuthenticationChallengeResponse> {
|
||||||
|
self.prune_expired(conn);
|
||||||
|
|
||||||
|
let stored: Vec<UserPasskey> = passkey_dsl::user_passkeys
|
||||||
|
.filter(passkey_dsl::user_id.eq(user.id))
|
||||||
|
.filter(passkey_dsl::revoked_at.is_null())
|
||||||
|
.select(UserPasskey::as_select())
|
||||||
|
.load(conn)?;
|
||||||
|
|
||||||
|
if stored.is_empty() {
|
||||||
|
return Err(AppError::bad_request("no passkeys registered"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut passkeys = Vec::with_capacity(stored.len());
|
||||||
|
for pk in &stored {
|
||||||
|
let passkey: Passkey = serde_json::from_value(pk.credential.clone())
|
||||||
|
.context("failed to parse stored passkey")
|
||||||
|
.map_err(AppError::internal)?;
|
||||||
|
passkeys.push(passkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (challenge, state) = self
|
||||||
|
.webauthn
|
||||||
|
.start_passkey_authentication(&passkeys)
|
||||||
|
.map_err(|err| {
|
||||||
|
tracing::error!(error = %err, "failed to start passkey authentication");
|
||||||
|
AppError::internal("failed to start passkey authentication")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let challenge_id = Uuid::new_v4();
|
||||||
|
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
|
||||||
|
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
|
||||||
|
let state_bytes = serde_json::to_vec(&state)
|
||||||
|
.context("failed to encode authentication state")
|
||||||
|
.map_err(AppError::internal)?;
|
||||||
|
|
||||||
|
let record = NewWebauthnChallenge {
|
||||||
|
id: challenge_id,
|
||||||
|
user_id: Some(user.id),
|
||||||
|
purpose: PURPOSE_AUTHENTICATION.to_string(),
|
||||||
|
challenge: challenge_bytes,
|
||||||
|
state: state_bytes,
|
||||||
|
expires_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::insert_into(challenge_dsl::webauthn_challenges)
|
||||||
|
.values(&record)
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
Ok(AuthenticationChallengeResponse {
|
||||||
|
challenge_id,
|
||||||
|
challenge,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_for_user(
|
||||||
|
&self,
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> AppResult<Vec<PasskeySummary>> {
|
||||||
|
let passkeys: Vec<UserPasskey> = passkey_dsl::user_passkeys
|
||||||
|
.filter(passkey_dsl::user_id.eq(user_id))
|
||||||
|
.order(passkey_dsl::created_at.asc())
|
||||||
|
.select(UserPasskey::as_select())
|
||||||
|
.load(conn)?;
|
||||||
|
|
||||||
|
Ok(passkeys.into_iter().map(PasskeySummary::from).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_passkey_count(&self, conn: &mut PgConnection, user_id: Uuid) -> AppResult<i64> {
|
||||||
|
let count: i64 = passkey_dsl::user_passkeys
|
||||||
|
.filter(passkey_dsl::user_id.eq(user_id))
|
||||||
|
.filter(passkey_dsl::revoked_at.is_null())
|
||||||
|
.select(count_star())
|
||||||
|
.first(conn)?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn consume_signup_challenge(
|
||||||
|
&self,
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
challenge_id: Uuid,
|
||||||
|
credential: &RegisterPublicKeyCredential,
|
||||||
|
) -> AppResult<PreparedPasskey> {
|
||||||
|
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
|
||||||
|
.find(challenge_id)
|
||||||
|
.first(conn)
|
||||||
|
.map_err(|err| {
|
||||||
|
if matches!(err, diesel::result::Error::NotFound) {
|
||||||
|
AppError::bad_request("challenge not found")
|
||||||
|
} else {
|
||||||
|
AppError::from(err)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if record.purpose != PURPOSE_REGISTRATION {
|
||||||
|
return Err(AppError::bad_request("challenge is not for registration"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.expires_at < Utc::now().naive_utc() {
|
||||||
|
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
||||||
|
return Err(AppError::bad_request("challenge expired"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let state: PasskeyRegistration = serde_json::from_slice(&record.state)
|
||||||
|
.context("failed to decode registration state")
|
||||||
|
.map_err(AppError::internal)?;
|
||||||
|
|
||||||
|
let passkey = self
|
||||||
|
.webauthn
|
||||||
|
.finish_passkey_registration(credential, &state)
|
||||||
|
.map_err(|err| {
|
||||||
|
tracing::warn!(error = %err, "passkey registration validation failed");
|
||||||
|
AppError::bad_request("invalid passkey attestation")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let credential_struct: Credential = passkey.clone().into();
|
||||||
|
let credential_id_vec: Vec<u8> = credential_struct.cred_id.clone().into();
|
||||||
|
|
||||||
|
let duplicate = passkey_dsl::user_passkeys
|
||||||
|
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
|
||||||
|
.first::<UserPasskey>(conn)
|
||||||
|
.optional()?;
|
||||||
|
if duplicate.is_some() {
|
||||||
|
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
||||||
|
return Err(AppError::conflict("credential already registered"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred)
|
||||||
|
.context("failed to encode credential public key")
|
||||||
|
.map_err(AppError::internal)?;
|
||||||
|
|
||||||
|
let transports: Vec<Option<String>> = credential_struct
|
||||||
|
.transports
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|transport| Some(transport.as_ref().to_string()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let aaguid = match credential_struct.attestation.metadata {
|
||||||
|
AttestationMetadata::Packed { aaguid } | AttestationMetadata::Tpm { aaguid, .. } => {
|
||||||
|
Some(aaguid)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let credential_json = serde_json::to_value(&passkey)
|
||||||
|
.context("failed to serialise passkey")
|
||||||
|
.map_err(AppError::internal)?;
|
||||||
|
|
||||||
|
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
||||||
|
|
||||||
|
Ok(PreparedPasskey {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
credential_id: credential_id_vec,
|
||||||
|
public_key: public_key_bytes,
|
||||||
|
credential: credential_json,
|
||||||
|
sign_count: credential_struct.counter as i64,
|
||||||
|
transports,
|
||||||
|
aaguid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn revoke_passkey(
|
||||||
|
&self,
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
user_id: Uuid,
|
||||||
|
passkey_id: Uuid,
|
||||||
|
reason: Option<String>,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let updated = diesel::update(
|
||||||
|
passkey_dsl::user_passkeys
|
||||||
|
.filter(passkey_dsl::id.eq(passkey_id))
|
||||||
|
.filter(passkey_dsl::user_id.eq(user_id))
|
||||||
|
.filter(passkey_dsl::revoked_at.is_null()),
|
||||||
|
)
|
||||||
|
.set((
|
||||||
|
passkey_dsl::revoked_at.eq(Some(now)),
|
||||||
|
passkey_dsl::revoked_reason.eq(reason),
|
||||||
|
passkey_dsl::updated_at.eq(now),
|
||||||
|
))
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
if updated == 0 {
|
||||||
|
return Err(AppError::not_found());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finish_authentication(
|
||||||
|
&self,
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
challenge_id: Uuid,
|
||||||
|
credential: PublicKeyCredential,
|
||||||
|
) -> AppResult<(User, UserPasskey, AuthenticationResult)> {
|
||||||
|
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
|
||||||
|
.find(challenge_id)
|
||||||
|
.first(conn)
|
||||||
|
.map_err(|err| {
|
||||||
|
if matches!(err, diesel::result::Error::NotFound) {
|
||||||
|
AppError::bad_request("challenge not found")
|
||||||
|
} else {
|
||||||
|
AppError::from(err)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if record.purpose != PURPOSE_AUTHENTICATION {
|
||||||
|
return Err(AppError::bad_request("challenge is not for authentication"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_id = record
|
||||||
|
.user_id
|
||||||
|
.ok_or_else(|| AppError::bad_request("challenge missing user context"))?;
|
||||||
|
|
||||||
|
if record.expires_at < Utc::now().naive_utc() {
|
||||||
|
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
||||||
|
return Err(AppError::bad_request("challenge expired"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let state: PasskeyAuthentication = serde_json::from_slice(&record.state)
|
||||||
|
.context("failed to decode authentication state")
|
||||||
|
.map_err(AppError::internal)?;
|
||||||
|
|
||||||
|
let auth_result = self
|
||||||
|
.webauthn
|
||||||
|
.finish_passkey_authentication(&credential, &state)
|
||||||
|
.map_err(|err| {
|
||||||
|
tracing::warn!(error = %err, "passkey authentication failed");
|
||||||
|
AppError::unauthorized()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let credential_id_vec: Vec<u8> = auth_result.cred_id().clone().into();
|
||||||
|
|
||||||
|
let mut passkey: UserPasskey = passkey_dsl::user_passkeys
|
||||||
|
.filter(passkey_dsl::user_id.eq(user_id))
|
||||||
|
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
|
||||||
|
.filter(passkey_dsl::revoked_at.is_null())
|
||||||
|
.select(UserPasskey::as_select())
|
||||||
|
.first(conn)
|
||||||
|
.map_err(|err| {
|
||||||
|
if matches!(err, diesel::result::Error::NotFound) {
|
||||||
|
AppError::unauthorized()
|
||||||
|
} else {
|
||||||
|
AppError::from(err)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut passkey_model: Passkey = serde_json::from_value(passkey.credential.clone())
|
||||||
|
.context("failed to parse stored passkey")
|
||||||
|
.map_err(AppError::internal)?;
|
||||||
|
|
||||||
|
if auth_result.needs_update() {
|
||||||
|
let _ = passkey_model.update_credential(&auth_result);
|
||||||
|
}
|
||||||
|
|
||||||
|
let credential_struct: Credential = passkey_model.clone().into();
|
||||||
|
let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred)
|
||||||
|
.context("failed to encode credential public key")
|
||||||
|
.map_err(AppError::internal)?;
|
||||||
|
|
||||||
|
let transports: Vec<Option<String>> = credential_struct
|
||||||
|
.transports
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|transport| Some(transport.as_ref().to_string()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let credential_json = serde_json::to_value(&passkey_model)
|
||||||
|
.context("failed to serialise passkey")
|
||||||
|
.map_err(AppError::internal)?;
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
diesel::update(passkey_dsl::user_passkeys.find(passkey.id))
|
||||||
|
.set((
|
||||||
|
passkey_dsl::sign_count.eq(auth_result.counter() as i64),
|
||||||
|
passkey_dsl::transports.eq(&transports),
|
||||||
|
passkey_dsl::credential.eq(credential_json.clone()),
|
||||||
|
passkey_dsl::public_key.eq(public_key_bytes),
|
||||||
|
passkey_dsl::last_used_at.eq(Some(now)),
|
||||||
|
passkey_dsl::updated_at.eq(now),
|
||||||
|
))
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
passkey.sign_count = auth_result.counter() as i64;
|
||||||
|
passkey.transports = transports;
|
||||||
|
passkey.credential = credential_json;
|
||||||
|
passkey.last_used_at = Some(now);
|
||||||
|
passkey.updated_at = now;
|
||||||
|
|
||||||
|
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
||||||
|
|
||||||
|
let user = crate::schema::users::table
|
||||||
|
.find(user_id)
|
||||||
|
.first::<User>(conn)?;
|
||||||
|
|
||||||
|
Ok((user, passkey, auth_result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<UserPasskey> for PasskeySummary {
|
||||||
|
fn from(passkey: UserPasskey) -> Self {
|
||||||
|
let transports = passkey
|
||||||
|
.transports
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|value| value)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
id: passkey.id,
|
||||||
|
nickname: passkey.nickname,
|
||||||
|
created_at: passkey.created_at,
|
||||||
|
last_used_at: passkey.last_used_at,
|
||||||
|
transports,
|
||||||
|
revoked_at: passkey.revoked_at,
|
||||||
|
revoked_reason: passkey.revoked_reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PasskeyRegistrationFinishPayload {
|
||||||
|
pub challenge_id: Uuid,
|
||||||
|
pub credential: RegisterPublicKeyCredential,
|
||||||
|
#[serde(default)]
|
||||||
|
pub nickname: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PasskeyLoginStartPayload {
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PasskeyLoginFinishPayload {
|
||||||
|
pub challenge_id: Uuid,
|
||||||
|
pub credential: PublicKeyCredential,
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use argon2::{
|
use argon2::{
|
||||||
password_hash::{PasswordHash, PasswordVerifier},
|
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
Argon2,
|
Argon2,
|
||||||
};
|
};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
|
||||||
pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
|
pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
|
||||||
let parsed_hash = PasswordHash::new(password_hash).map_err(|err| anyhow!(err))?;
|
let parsed_hash = PasswordHash::new(password_hash).map_err(|err| anyhow!(err))?;
|
||||||
@@ -10,3 +11,11 @@ pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
|
|||||||
.verify_password(password.as_bytes(), &parsed_hash)
|
.verify_password(password.as_bytes(), &parsed_hash)
|
||||||
.is_ok())
|
.is_ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn hash_password(password: &str) -> Result<String> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|err| anyhow!(err))?;
|
||||||
|
Ok(hash.to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -140,15 +140,18 @@ pub fn touch_webdav_token(conn: &mut PgPooledConnection, token_id: Uuid) -> Resu
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result<bool, AppError> {
|
pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result<bool, AppError> {
|
||||||
crate::auth::password::verify_password(secret, token_hash)
|
crate::auth::password::verify_password(secret, token_hash).map_err(|err| {
|
||||||
.map_err(|err| AppError::internal(format!("failed to verify token: {err}")))
|
tracing::error!(error = ?err, "failed to verify token");
|
||||||
|
AppError::internal("failed to verify token")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_secret() -> Result<String, AppError> {
|
fn generate_secret() -> Result<String, AppError> {
|
||||||
let mut buffer = [0u8; TOKEN_SECRET_LENGTH];
|
let mut buffer = [0u8; TOKEN_SECRET_LENGTH];
|
||||||
OsRng
|
OsRng.try_fill_bytes(&mut buffer).map_err(|err| {
|
||||||
.try_fill_bytes(&mut buffer)
|
tracing::error!(error = ?err, "failed to generate token");
|
||||||
.map_err(|err| AppError::internal(format!("failed to generate token: {err}")))?;
|
AppError::internal("failed to generate token")
|
||||||
|
})?;
|
||||||
Ok(hex::encode(buffer))
|
Ok(hex::encode(buffer))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +159,10 @@ fn hash_secret(secret: &str) -> Result<String, AppError> {
|
|||||||
let salt = SaltString::generate(&mut OsRng);
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
let hash = Argon2::default()
|
let hash = Argon2::default()
|
||||||
.hash_password(secret.as_bytes(), &salt)
|
.hash_password(secret.as_bytes(), &salt)
|
||||||
.map_err(|err| AppError::internal(format!("failed to hash token: {err}")))?;
|
.map_err(|err| {
|
||||||
|
tracing::error!(error = ?err, "failed to hash token");
|
||||||
|
AppError::internal("failed to hash token")
|
||||||
|
})?;
|
||||||
Ok(hash.to_string())
|
Ok(hash.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+140
-290
@@ -2,115 +2,81 @@ use std::env;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
use argon2::{
|
|
||||||
password_hash::{PasswordHasher, SaltString},
|
|
||||||
Argon2,
|
|
||||||
};
|
|
||||||
use diesel::{dsl::exists, prelude::*, select};
|
use diesel::{dsl::exists, prelude::*, select};
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use reqwest::{Client, Method, StatusCode};
|
use reqwest::{Client, Method, StatusCode};
|
||||||
use serde_json::json;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use backend::{
|
use backend::{
|
||||||
config::AppConfig,
|
config::AppConfig,
|
||||||
db::{self, PgPool},
|
db::{self, PgPool},
|
||||||
|
documents::search::ensure_quickwit_index,
|
||||||
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT},
|
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT},
|
||||||
models::{DocumentAsset, DocumentAssetObject, NewUser, NewUserMembership, Tenant, User},
|
models::{
|
||||||
|
DocumentAsset, DocumentAssetObject, 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, tenants, user_memberships, users,
|
||||||
},
|
},
|
||||||
storage::{ObjectStorage, S3Storage, TenantStorage},
|
storage::{ObjectStorage, S3Storage, TenantStorage},
|
||||||
|
tenants::TenantService,
|
||||||
utils::tracing::init_tracing,
|
utils::tracing::init_tracing,
|
||||||
};
|
};
|
||||||
|
|
||||||
use rand::rngs::OsRng;
|
|
||||||
|
|
||||||
static QUICKWIT_INDEX_TEMPLATE: Lazy<serde_json::Value> = Lazy::new(|| {
|
|
||||||
json!({
|
|
||||||
"version": "0.8",
|
|
||||||
"index_id": "documents",
|
|
||||||
"doc_mapping": {
|
|
||||||
"tokenizers": [
|
|
||||||
{
|
|
||||||
"name": "substring",
|
|
||||||
"type": "ngram",
|
|
||||||
"min_gram": 2,
|
|
||||||
"max_gram": 20,
|
|
||||||
"prefix_only": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"field_mappings": [
|
|
||||||
{ "name": "tenant_id", "type": "text", "stored": true },
|
|
||||||
{ "name": "document_id", "type": "text", "stored": true },
|
|
||||||
{ "name": "version_id", "type": "text", "stored": true },
|
|
||||||
{ "name": "title", "type": "text", "tokenizer": "substring", "stored": true },
|
|
||||||
{ "name": "text", "type": "text", "tokenizer": "substring", "record": "position" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"search_settings": {
|
|
||||||
"default_search_fields": ["title", "text"]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum Command {
|
enum Command {
|
||||||
CreateUser {
|
CreateUser {
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
|
||||||
},
|
|
||||||
SetPassword {
|
|
||||||
username: String,
|
|
||||||
password: String,
|
|
||||||
},
|
},
|
||||||
ListUsers,
|
ListUsers,
|
||||||
DeleteUser {
|
DeleteUser {
|
||||||
username: String,
|
username: String,
|
||||||
},
|
},
|
||||||
CreateTenant {
|
CreateTenant {
|
||||||
slug: String,
|
name: String,
|
||||||
storage_root: Option<String>,
|
storage_root: Option<String>,
|
||||||
quickwit_index: Option<String>,
|
quickwit_index: Option<String>,
|
||||||
},
|
},
|
||||||
DeleteTenant {
|
DeleteTenant {
|
||||||
slug: String,
|
tenant_id: Uuid,
|
||||||
},
|
},
|
||||||
AddUserToTenant {
|
AddUserToTenant {
|
||||||
username: String,
|
username: String,
|
||||||
slug: String,
|
tenant_id: Uuid,
|
||||||
role: Option<String>,
|
|
||||||
},
|
},
|
||||||
RemoveUserFromTenant {
|
RemoveUserFromTenant {
|
||||||
username: String,
|
username: String,
|
||||||
slug: String,
|
tenant_id: Uuid,
|
||||||
},
|
},
|
||||||
ReanalyzeDocuments {
|
ReanalyzeDocuments {
|
||||||
slug: String,
|
tenant_id: Uuid,
|
||||||
},
|
},
|
||||||
ListTenants,
|
ListTenants,
|
||||||
DeleteAssets(String),
|
DeleteAssets(Uuid),
|
||||||
QuickwitCreate(String),
|
QuickwitCreate(Uuid),
|
||||||
QuickwitDelete(String),
|
QuickwitDelete(Uuid),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Command {
|
impl Command {
|
||||||
fn usage() -> &'static str {
|
fn usage() -> &'static str {
|
||||||
"Usage: admin\n\
|
"Usage: admin\n\
|
||||||
create-user <username> <password>\n\
|
create-user <username>\n\
|
||||||
set-password <username> <password>\n\
|
|
||||||
list-users\n\
|
list-users\n\
|
||||||
delete-user <username>\n\
|
delete-user <username>\n\
|
||||||
create-tenant <slug> [storage_root] [quickwit_index]\n\
|
create-tenant <name> [storage_root] [quickwit_index]\n\
|
||||||
delete-tenant <slug>\n\
|
delete-tenant <tenant-id>\n\
|
||||||
add-user-to-tenant <username> <slug> [role]\n\
|
add-user-to-tenant <username> <tenant-id>\n\
|
||||||
remove-user-from-tenant <username> <slug>\n\
|
remove-user-from-tenant <username> <tenant-id>\n\
|
||||||
reanalyze-documents <slug>\n\
|
reanalyze-documents <tenant-id>\n\
|
||||||
list-tenants\n\
|
list-tenants\n\
|
||||||
delete-assets <slug>\n\
|
delete-assets <tenant-id>\n\
|
||||||
quickwit-create-index <slug>\n\
|
quickwit-create-index <tenant-id>\n\
|
||||||
quickwit-delete-index <slug>"
|
quickwit-delete-index <tenant-id>"
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
fn parse() -> Result<Self> {
|
||||||
@@ -118,46 +84,38 @@ impl Command {
|
|||||||
match args.next().as_deref() {
|
match args.next().as_deref() {
|
||||||
Some("create-user") => Ok(Self::CreateUser {
|
Some("create-user") => Ok(Self::CreateUser {
|
||||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||||
password: args.next().ok_or_else(|| anyhow!("password required"))?,
|
|
||||||
}),
|
|
||||||
Some("set-password") => Ok(Self::SetPassword {
|
|
||||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
|
||||||
password: args.next().ok_or_else(|| anyhow!("password required"))?,
|
|
||||||
}),
|
}),
|
||||||
Some("list-users") => Ok(Self::ListUsers),
|
Some("list-users") => Ok(Self::ListUsers),
|
||||||
Some("delete-user") => Ok(Self::DeleteUser {
|
Some("delete-user") => Ok(Self::DeleteUser {
|
||||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||||
}),
|
}),
|
||||||
Some("create-tenant") => Ok(Self::CreateTenant {
|
Some("create-tenant") => Ok(Self::CreateTenant {
|
||||||
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
name: args.next().ok_or_else(|| anyhow!("tenant name required"))?,
|
||||||
storage_root: args.next(),
|
storage_root: args.next(),
|
||||||
quickwit_index: args.next(),
|
quickwit_index: args.next(),
|
||||||
}),
|
}),
|
||||||
Some("delete-tenant") => Ok(Self::DeleteTenant {
|
Some("delete-tenant") => Ok(Self::DeleteTenant {
|
||||||
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
tenant_id: Self::parse_tenant_id(args.next())?,
|
||||||
}),
|
}),
|
||||||
Some("add-user-to-tenant") => Ok(Self::AddUserToTenant {
|
Some("add-user-to-tenant") => Ok(Self::AddUserToTenant {
|
||||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||||
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
tenant_id: Self::parse_tenant_id(args.next())?,
|
||||||
role: args.next(),
|
|
||||||
}),
|
}),
|
||||||
Some("remove-user-from-tenant") => Ok(Self::RemoveUserFromTenant {
|
Some("remove-user-from-tenant") => Ok(Self::RemoveUserFromTenant {
|
||||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||||
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
tenant_id: Self::parse_tenant_id(args.next())?,
|
||||||
}),
|
}),
|
||||||
Some("reanalyze-documents") => Ok(Self::ReanalyzeDocuments {
|
Some("reanalyze-documents") => Ok(Self::ReanalyzeDocuments {
|
||||||
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
tenant_id: Self::parse_tenant_id(args.next())?,
|
||||||
}),
|
}),
|
||||||
Some("list-tenants") => Ok(Self::ListTenants),
|
Some("list-tenants") => Ok(Self::ListTenants),
|
||||||
Some("delete-assets") => Ok(Self::DeleteAssets(
|
Some("delete-assets") => Ok(Self::DeleteAssets(Self::parse_tenant_id(args.next())?)),
|
||||||
args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
Some("quickwit-create-index") => {
|
||||||
)),
|
Ok(Self::QuickwitCreate(Self::parse_tenant_id(args.next())?))
|
||||||
Some("quickwit-create-index") => Ok(Self::QuickwitCreate(
|
}
|
||||||
args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
Some("quickwit-delete-index") => {
|
||||||
)),
|
Ok(Self::QuickwitDelete(Self::parse_tenant_id(args.next())?))
|
||||||
Some("quickwit-delete-index") => Ok(Self::QuickwitDelete(
|
}
|
||||||
args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
|
||||||
)),
|
|
||||||
_ => Err(anyhow!(Self::usage())),
|
_ => Err(anyhow!(Self::usage())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,45 +129,43 @@ async fn main() -> Result<()> {
|
|||||||
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 command {
|
||||||
Command::CreateUser { username, password } => create_user(&pool, &username, &password)?,
|
Command::CreateUser { username } => create_user(&pool, &username)?,
|
||||||
Command::SetPassword { username, password } => set_password(&pool, &username, &password)?,
|
|
||||||
Command::ListUsers => list_users(&pool)?,
|
Command::ListUsers => list_users(&pool)?,
|
||||||
Command::DeleteUser { username } => delete_user(&pool, &username)?,
|
Command::DeleteUser { username } => delete_user(&pool, &username)?,
|
||||||
Command::CreateTenant {
|
Command::CreateTenant {
|
||||||
slug,
|
name,
|
||||||
storage_root,
|
storage_root,
|
||||||
quickwit_index,
|
quickwit_index,
|
||||||
} => create_tenant(&pool, &slug, storage_root, quickwit_index)?,
|
} => create_tenant(&pool, &name, storage_root, quickwit_index)?,
|
||||||
Command::DeleteTenant { slug } => delete_tenant(&pool, &slug)?,
|
Command::DeleteTenant { tenant_id } => delete_tenant(&pool, tenant_id)?,
|
||||||
Command::AddUserToTenant {
|
Command::AddUserToTenant {
|
||||||
username,
|
username,
|
||||||
slug,
|
tenant_id,
|
||||||
role,
|
} => add_user_to_tenant(&pool, &username, tenant_id)?,
|
||||||
} => add_user_to_tenant(&pool, &username, &slug, role.as_deref())?,
|
Command::RemoveUserFromTenant {
|
||||||
Command::RemoveUserFromTenant { username, slug } => {
|
username,
|
||||||
remove_user_from_tenant(&pool, &username, &slug)?
|
tenant_id,
|
||||||
}
|
} => remove_user_from_tenant(&pool, &username, tenant_id)?,
|
||||||
Command::ReanalyzeDocuments { slug } => reanalyze_documents(&pool, &slug)?,
|
Command::ReanalyzeDocuments { tenant_id } => reanalyze_documents(&pool, tenant_id)?,
|
||||||
Command::ListTenants => list_tenants(&pool)?,
|
Command::ListTenants => list_tenants(&pool)?,
|
||||||
Command::DeleteAssets(slug) => delete_assets_for_tenant(&config, &pool, &slug).await?,
|
Command::DeleteAssets(tenant_id) => {
|
||||||
Command::QuickwitCreate(slug) => {
|
delete_assets_for_tenant(&config, &pool, tenant_id).await?
|
||||||
quickwit_index(&config, &pool, &slug, Method::POST).await?
|
|
||||||
}
|
}
|
||||||
Command::QuickwitDelete(slug) => {
|
Command::QuickwitCreate(tenant_id) => {
|
||||||
quickwit_index(&config, &pool, &slug, Method::DELETE).await?
|
quickwit_index(&config, &pool, tenant_id, Method::POST).await?
|
||||||
|
}
|
||||||
|
Command::QuickwitDelete(tenant_id) => {
|
||||||
|
quickwit_index(&config, &pool, tenant_id, Method::DELETE).await?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> {
|
fn create_user(pool: &PgPool, username: &str) -> Result<()> {
|
||||||
if username.trim().is_empty() {
|
if username.trim().is_empty() {
|
||||||
bail!("username must not be empty");
|
bail!("username must not be empty");
|
||||||
}
|
}
|
||||||
if password.is_empty() {
|
|
||||||
bail!("password must not be empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
let exists: bool =
|
let exists: bool =
|
||||||
@@ -218,11 +174,9 @@ fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> {
|
|||||||
bail!("user '{}' already exists", username);
|
bail!("user '{}' already exists", username);
|
||||||
}
|
}
|
||||||
|
|
||||||
let password_hash = hash_password(password)?;
|
|
||||||
let new_user = NewUser {
|
let new_user = NewUser {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
password_hash,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(users::table)
|
diesel::insert_into(users::table)
|
||||||
@@ -233,34 +187,6 @@ fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_password(pool: &PgPool, username: &str, password: &str) -> Result<()> {
|
|
||||||
if password.is_empty() {
|
|
||||||
bail!("password must not be empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
|
||||||
let password_hash = hash_password(password)?;
|
|
||||||
|
|
||||||
let updated = diesel::update(users::table.filter(users::username.eq(username)))
|
|
||||||
.set(users::password_hash.eq(password_hash))
|
|
||||||
.execute(&mut conn)?;
|
|
||||||
|
|
||||||
if updated == 0 {
|
|
||||||
bail!("user '{}' not found", username);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("updated password for '{}'", username);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash_password(password: &str) -> Result<String> {
|
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
|
||||||
let hash = Argon2::default()
|
|
||||||
.hash_password(password.as_bytes(), &salt)
|
|
||||||
.map_err(|err| anyhow!(err))?;
|
|
||||||
Ok(hash.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_users(pool: &PgPool) -> Result<()> {
|
fn list_users(pool: &PgPool) -> Result<()> {
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
|
|
||||||
@@ -271,21 +197,22 @@ fn list_users(pool: &PgPool) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for user in users_list {
|
for user in users_list {
|
||||||
let memberships: Vec<(Uuid, String, String)> = user_memberships::table
|
let memberships: Vec<String> = user_memberships::table
|
||||||
.inner_join(tenants::table)
|
.inner_join(tenants::table)
|
||||||
.filter(user_memberships::user_id.eq(user.id))
|
.filter(user_memberships::user_id.eq(user.id))
|
||||||
.select((tenants::id, tenants::slug, user_memberships::role))
|
.select(tenants::name)
|
||||||
.order((tenants::slug.asc(), user_memberships::role.asc()))
|
.order(tenants::name.asc())
|
||||||
.load(&mut conn)?;
|
.load(&mut conn)?;
|
||||||
|
|
||||||
if memberships.is_empty() {
|
if memberships.is_empty() {
|
||||||
println!("{} ({})", user.username, user.id);
|
println!("{} ({})", user.username, user.id);
|
||||||
} else {
|
} else {
|
||||||
let details: Vec<String> = memberships
|
println!(
|
||||||
.into_iter()
|
"{} ({}) -> {}",
|
||||||
.map(|(_, slug, role)| format!("{}: {}", slug, role))
|
user.username,
|
||||||
.collect();
|
user.id,
|
||||||
println!("{} ({}) -> {}", user.username, user.id, details.join(", "));
|
memberships.join(", ")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,77 +238,59 @@ fn delete_user(pool: &PgPool, username: &str) -> Result<()> {
|
|||||||
|
|
||||||
fn create_tenant(
|
fn create_tenant(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
slug: &str,
|
name: &str,
|
||||||
storage_root_arg: Option<String>,
|
storage_root_arg: Option<String>,
|
||||||
quickwit_index_arg: Option<String>,
|
quickwit_index_arg: Option<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if slug.trim().is_empty() {
|
let service = TenantService::new(pool.clone());
|
||||||
bail!("tenant slug must not be empty");
|
let tenant = service
|
||||||
}
|
.create_tenant(
|
||||||
|
name,
|
||||||
|
storage_root_arg.as_deref(),
|
||||||
|
quickwit_index_arg.as_deref(),
|
||||||
|
TenantStatus::Creating,
|
||||||
|
&[],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|err| anyhow!(format!("{err:?}")))?;
|
||||||
|
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
let storage_root = tenant.storage_root.as_deref().unwrap_or("<none>");
|
||||||
let exists: bool =
|
let quickwit_index = tenant.quickwit_index.as_deref().unwrap_or("<none>");
|
||||||
select(exists(tenants::table.filter(tenants::slug.eq(slug)))).get_result(&mut conn)?;
|
|
||||||
if exists {
|
|
||||||
bail!("tenant '{}' already exists", slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
let id = Uuid::new_v4();
|
|
||||||
let storage_root = storage_root_arg
|
|
||||||
.map(|mut s| {
|
|
||||||
if s.is_empty() {
|
|
||||||
format!("tenants/{}/", id)
|
|
||||||
} else {
|
|
||||||
if !s.ends_with('/') {
|
|
||||||
s.push('/');
|
|
||||||
}
|
|
||||||
s
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| format!("tenants/{}/", id));
|
|
||||||
let quickwit_index = quickwit_index_arg.unwrap_or_else(|| format!("documents-{}", id));
|
|
||||||
|
|
||||||
diesel::insert_into(tenants::table)
|
|
||||||
.values((
|
|
||||||
tenants::id.eq(id),
|
|
||||||
tenants::slug.eq(slug),
|
|
||||||
tenants::storage_root.eq(Some(storage_root.clone())),
|
|
||||||
tenants::quickwit_index.eq(Some(quickwit_index.clone())),
|
|
||||||
tenants::status.eq("active"),
|
|
||||||
tenants::config.eq(serde_json::json!({})),
|
|
||||||
))
|
|
||||||
.execute(&mut conn)?;
|
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"created tenant '{}' with id {}, storage_root '{}', quickwit_index '{}'",
|
"created tenant '{}' with id {}, storage_root '{}', quickwit_index '{}', status '{}'",
|
||||||
slug, id, storage_root, quickwit_index
|
tenant.name,
|
||||||
|
tenant.id,
|
||||||
|
storage_root,
|
||||||
|
quickwit_index,
|
||||||
|
tenant.status.as_str()
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete_tenant(pool: &PgPool, slug: &str) -> 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")?;
|
||||||
|
|
||||||
let tenant: Tenant = tenants::table
|
let tenant: Tenant = tenants::table
|
||||||
.filter(tenants::slug.eq(slug))
|
.find(tenant_id)
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.optional()?
|
.optional()?
|
||||||
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
|
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
||||||
|
|
||||||
let member_exists: bool = select(exists(
|
let member_exists: bool = select(exists(
|
||||||
user_memberships::table.filter(user_memberships::tenant_id.eq(tenant.id)),
|
user_memberships::table.filter(user_memberships::tenant_id.eq(tenant.id)),
|
||||||
))
|
))
|
||||||
.get_result(&mut conn)?;
|
.get_result(&mut conn)?;
|
||||||
if member_exists {
|
if member_exists {
|
||||||
bail!("tenant '{}' still has user memberships", slug);
|
bail!("tenant '{}' still has user memberships", tenant.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
diesel::delete(tenants::table.filter(tenants::id.eq(tenant.id))).execute(&mut conn)?;
|
diesel::delete(tenants::table.filter(tenants::id.eq(tenant.id))).execute(&mut conn)?;
|
||||||
println!("deleted tenant '{}'", slug);
|
println!("deleted tenant '{}'", tenant.name);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_user_to_tenant(pool: &PgPool, username: &str, slug: &str, role: Option<&str>) -> Result<()> {
|
fn add_user_to_tenant(pool: &PgPool, username: &str, 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")?;
|
||||||
|
|
||||||
let user: User = users::table
|
let user: User = users::table
|
||||||
@@ -391,33 +300,28 @@ fn add_user_to_tenant(pool: &PgPool, username: &str, slug: &str, role: Option<&s
|
|||||||
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
||||||
|
|
||||||
let tenant: Tenant = tenants::table
|
let tenant: Tenant = tenants::table
|
||||||
.filter(tenants::slug.eq(slug))
|
.find(tenant_id)
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.optional()?
|
.optional()?
|
||||||
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
|
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
||||||
|
|
||||||
let membership = NewUserMembership {
|
let membership = NewUserMembership {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
tenant_id: tenant.id,
|
tenant_id: tenant.id,
|
||||||
role: role.unwrap_or("user").to_string(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(user_memberships::table)
|
diesel::insert_into(user_memberships::table)
|
||||||
.values(&membership)
|
.values(&membership)
|
||||||
.on_conflict((user_memberships::user_id, user_memberships::tenant_id))
|
.on_conflict((user_memberships::user_id, user_memberships::tenant_id))
|
||||||
.do_update()
|
.do_nothing()
|
||||||
.set(user_memberships::role.eq(&membership.role))
|
|
||||||
.execute(&mut conn)?;
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
println!(
|
println!("added user '{}' to tenant '{}'", username, tenant.name);
|
||||||
"added user '{}' to tenant '{}' with role '{}'",
|
|
||||||
username, slug, membership.role
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_user_from_tenant(pool: &PgPool, username: &str, slug: &str) -> Result<()> {
|
fn remove_user_from_tenant(pool: &PgPool, username: &str, 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")?;
|
||||||
|
|
||||||
let user: User = users::table
|
let user: User = users::table
|
||||||
@@ -427,10 +331,10 @@ fn remove_user_from_tenant(pool: &PgPool, username: &str, slug: &str) -> Result<
|
|||||||
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
||||||
|
|
||||||
let tenant: Tenant = tenants::table
|
let tenant: Tenant = tenants::table
|
||||||
.filter(tenants::slug.eq(slug))
|
.find(tenant_id)
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.optional()?
|
.optional()?
|
||||||
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
|
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
||||||
|
|
||||||
let removed = diesel::delete(
|
let removed = diesel::delete(
|
||||||
user_memberships::table
|
user_memberships::table
|
||||||
@@ -440,21 +344,24 @@ fn remove_user_from_tenant(pool: &PgPool, username: &str, slug: &str) -> Result<
|
|||||||
.execute(&mut conn)?;
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
if removed == 0 {
|
if removed == 0 {
|
||||||
println!("user '{}' was not a member of tenant '{}'", username, slug);
|
println!(
|
||||||
|
"user '{}' was not a member of tenant '{}'",
|
||||||
|
username, tenant.name
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
println!("removed user '{}' from tenant '{}'", username, slug);
|
println!("removed user '{}' from tenant '{}'", username, tenant.name);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reanalyze_documents(pool: &PgPool, slug: &str) -> Result<()> {
|
fn reanalyze_documents(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")?;
|
||||||
|
|
||||||
let tenant: Tenant = tenants::table
|
let tenant: Tenant = tenants::table
|
||||||
.filter(tenants::slug.eq(slug))
|
.find(tenant_id)
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.optional()?
|
.optional()?
|
||||||
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
|
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
||||||
|
|
||||||
let targets: Vec<(Uuid, Uuid)> = documents::table
|
let targets: Vec<(Uuid, Uuid)> = documents::table
|
||||||
.filter(documents::tenant_id.eq(tenant.id))
|
.filter(documents::tenant_id.eq(tenant.id))
|
||||||
@@ -463,7 +370,7 @@ fn reanalyze_documents(pool: &PgPool, slug: &str) -> Result<()> {
|
|||||||
.load(&mut conn)?;
|
.load(&mut conn)?;
|
||||||
|
|
||||||
if targets.is_empty() {
|
if targets.is_empty() {
|
||||||
println!("tenant '{}' has no active documents", slug);
|
println!("tenant '{}' has no active documents", tenant.name);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,7 +393,7 @@ fn reanalyze_documents(pool: &PgPool, slug: &str) -> Result<()> {
|
|||||||
|
|
||||||
println!(
|
println!(
|
||||||
"queued {} documents for re-analysis in tenant '{}'",
|
"queued {} documents for re-analysis in tenant '{}'",
|
||||||
queued, slug
|
queued, tenant.name
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -494,7 +401,7 @@ fn reanalyze_documents(pool: &PgPool, slug: &str) -> Result<()> {
|
|||||||
fn list_tenants(pool: &PgPool) -> Result<()> {
|
fn list_tenants(pool: &PgPool) -> Result<()> {
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
let tenants: Vec<Tenant> = tenants::table
|
let tenants: Vec<Tenant> = tenants::table
|
||||||
.order(tenants::slug.asc())
|
.order(tenants::name.asc())
|
||||||
.load(&mut conn)
|
.load(&mut conn)
|
||||||
.context("failed to load tenants")?;
|
.context("failed to load tenants")?;
|
||||||
|
|
||||||
@@ -504,7 +411,7 @@ fn list_tenants(pool: &PgPool) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for tenant in tenants {
|
for tenant in tenants {
|
||||||
println!("{} ({})", tenant.slug, tenant.id);
|
println!("{} {}", tenant.id, tenant.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -513,7 +420,7 @@ fn list_tenants(pool: &PgPool) -> Result<()> {
|
|||||||
async fn delete_assets_for_tenant(
|
async fn delete_assets_for_tenant(
|
||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
tenant_slug: &str,
|
tenant_id: Uuid,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let s3_client = s3::build_client(config).await?;
|
let s3_client = s3::build_client(config).await?;
|
||||||
let storage: Arc<dyn ObjectStorage> =
|
let storage: Arc<dyn ObjectStorage> =
|
||||||
@@ -521,28 +428,27 @@ async fn delete_assets_for_tenant(
|
|||||||
|
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
let tenant: Tenant = tenants::table
|
let tenant: Tenant = tenants::table
|
||||||
.filter(tenants::slug.eq(tenant_slug))
|
.find(tenant_id)
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.optional()
|
.optional()?
|
||||||
.context("failed to load tenant")?
|
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
||||||
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_slug))?;
|
|
||||||
|
|
||||||
let tenant_storage = TenantStorage::new(Arc::clone(&storage), &tenant)
|
let tenant_storage = TenantStorage::new(Arc::clone(&storage), &tenant)
|
||||||
.with_context(|| format!("missing storage root for tenant {}", tenant.slug))?;
|
.with_context(|| format!("missing storage root for tenant {}", tenant.name))?;
|
||||||
|
|
||||||
let assets: Vec<DocumentAsset> = document_assets::table
|
let assets: Vec<DocumentAsset> = document_assets::table
|
||||||
.filter(document_assets::tenant_id.eq(tenant.id))
|
.filter(document_assets::tenant_id.eq(tenant.id))
|
||||||
.load(&mut conn)
|
.load(&mut conn)
|
||||||
.with_context(|| format!("failed to load assets for tenant {}", tenant.slug))?;
|
.with_context(|| format!("failed to load assets for tenant {}", tenant.name))?;
|
||||||
|
|
||||||
if assets.is_empty() {
|
if assets.is_empty() {
|
||||||
println!("Tenant {}: no assets", tenant.slug);
|
println!("Tenant {}: no assets", tenant.name);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"Tenant {} ({}): deleting {} assets…",
|
"Tenant {} ({}): deleting {} assets…",
|
||||||
tenant.slug,
|
tenant.name,
|
||||||
tenant.id,
|
tenant.id,
|
||||||
assets.len()
|
assets.len()
|
||||||
);
|
);
|
||||||
@@ -553,13 +459,13 @@ async fn delete_assets_for_tenant(
|
|||||||
.filter(document_asset_objects::tenant_id.eq(tenant.id))
|
.filter(document_asset_objects::tenant_id.eq(tenant.id))
|
||||||
.filter(document_asset_objects::asset_id.eq_any(&asset_ids))
|
.filter(document_asset_objects::asset_id.eq_any(&asset_ids))
|
||||||
.load(&mut conn)
|
.load(&mut conn)
|
||||||
.with_context(|| format!("failed to load asset objects for tenant {}", tenant.slug))?;
|
.with_context(|| format!("failed to load asset objects for tenant {}", tenant.name))?;
|
||||||
|
|
||||||
for object in &objects {
|
for object in &objects {
|
||||||
if let Err(err) = tenant_storage.delete_object(&object.s3_key).await {
|
if let Err(err) = tenant_storage.delete_object(&object.s3_key).await {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Failed to delete object {} (tenant {}): {err}",
|
"Failed to delete object {} (tenant {}): {err}",
|
||||||
object.s3_key, tenant.slug
|
object.s3_key, tenant.name
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -570,20 +476,20 @@ async fn delete_assets_for_tenant(
|
|||||||
.filter(document_asset_objects::asset_id.eq_any(&asset_ids)),
|
.filter(document_asset_objects::asset_id.eq_any(&asset_ids)),
|
||||||
)
|
)
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
.with_context(|| format!("failed to remove asset objects for tenant {}", tenant.slug))?;
|
.with_context(|| format!("failed to remove asset objects for tenant {}", tenant.name))?;
|
||||||
|
|
||||||
diesel::delete(document_assets::table.filter(document_assets::tenant_id.eq(tenant.id)))
|
diesel::delete(document_assets::table.filter(document_assets::tenant_id.eq(tenant.id)))
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
.with_context(|| format!("failed to remove asset records for tenant {}", tenant.slug))?;
|
.with_context(|| format!("failed to remove asset records for tenant {}", tenant.name))?;
|
||||||
|
|
||||||
println!("Tenant {}: asset records deleted.", tenant.slug);
|
println!("Tenant {}: asset records deleted.", tenant.name);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn quickwit_index(
|
async fn quickwit_index(
|
||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
slug: &str,
|
tenant_id: Uuid,
|
||||||
method: Method,
|
method: Method,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let endpoint = config
|
let endpoint = config
|
||||||
@@ -593,11 +499,10 @@ async fn quickwit_index(
|
|||||||
|
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
let tenant: Tenant = tenants::table
|
let tenant: Tenant = tenants::table
|
||||||
.filter(tenants::slug.eq(slug))
|
.find(tenant_id)
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.optional()
|
.optional()?
|
||||||
.context("failed to query tenants")?
|
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
||||||
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
|
|
||||||
|
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
let index_id = format!("documents-{}", tenant.id);
|
let index_id = format!("documents-{}", tenant.id);
|
||||||
@@ -605,63 +510,19 @@ async fn quickwit_index(
|
|||||||
|
|
||||||
match method {
|
match method {
|
||||||
Method::POST => {
|
Method::POST => {
|
||||||
let payload = render_index_template(&index_id);
|
ensure_quickwit_index(&client, base_endpoint, &index_id)
|
||||||
let response = client
|
|
||||||
.post(format!("{}/api/v1/indexes", base_endpoint))
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(payload)
|
|
||||||
.send()
|
|
||||||
.await
|
.await
|
||||||
.context("failed to send create index request")?;
|
.context("failed to ensure quickwit index")?;
|
||||||
|
|
||||||
match response.status() {
|
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
|
||||||
status if status.is_success() => {
|
.set(tenants::quickwit_index.eq(Some(index_id.clone())))
|
||||||
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
|
.execute(&mut conn)
|
||||||
.set(tenants::quickwit_index.eq(Some(index_id.clone())))
|
.context("failed to update tenant quickwit_index")?;
|
||||||
.execute(&mut conn)
|
|
||||||
.context("failed to update tenant quickwit_index")?;
|
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"Tenant '{}' quickwit index set to '{}'.",
|
"Tenant '{}' quickwit index set to '{}'.",
|
||||||
tenant.slug, index_id
|
tenant.name, index_id
|
||||||
);
|
);
|
||||||
}
|
|
||||||
StatusCode::CONFLICT => {
|
|
||||||
let lookup = client
|
|
||||||
.get(format!("{}/api/v1/indexes/{}", base_endpoint, index_id))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("failed to verify existing quickwit index")?;
|
|
||||||
|
|
||||||
let lookup_status = lookup.status();
|
|
||||||
if !lookup_status.is_success() {
|
|
||||||
let body = lookup.text().await.unwrap_or_default();
|
|
||||||
bail!(
|
|
||||||
"quickwit reported conflict but index lookup failed with status {}: {}",
|
|
||||||
lookup_status,
|
|
||||||
body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
|
|
||||||
.set(tenants::quickwit_index.eq(Some(index_id.clone())))
|
|
||||||
.execute(&mut conn)
|
|
||||||
.context("failed to update tenant quickwit_index")?;
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"Tenant '{}' quickwit index set to '{}'.",
|
|
||||||
tenant.slug, index_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
status => {
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
bail!(
|
|
||||||
"quickwit create index failed with status {}: {}",
|
|
||||||
status,
|
|
||||||
body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Method::DELETE => {
|
Method::DELETE => {
|
||||||
let response = client
|
let response = client
|
||||||
@@ -677,7 +538,7 @@ async fn quickwit_index(
|
|||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
.context("failed to clear tenant quickwit_index")?;
|
.context("failed to clear tenant quickwit_index")?;
|
||||||
|
|
||||||
println!("Tenant '{}' quickwit index cleared.", tenant.slug);
|
println!("Tenant '{}' quickwit index cleared.", tenant.name);
|
||||||
}
|
}
|
||||||
status => {
|
status => {
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
@@ -694,14 +555,3 @@ async fn quickwit_index(
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_index_template(index_id: &str) -> String {
|
|
||||||
let mut template = QUICKWIT_INDEX_TEMPLATE.clone();
|
|
||||||
if let Some(obj) = template.as_object_mut() {
|
|
||||||
obj.insert(
|
|
||||||
"index_id".to_string(),
|
|
||||||
serde_json::Value::String(index_id.to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
template.to_string()
|
|
||||||
}
|
|
||||||
|
|||||||
+39
-4
@@ -54,8 +54,16 @@ pub struct AppConfig {
|
|||||||
pub quickwit_endpoint: Option<String>,
|
pub quickwit_endpoint: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub quickwit_index: Option<String>,
|
pub quickwit_index: Option<String>,
|
||||||
#[serde(default = "default_tenant_slug")]
|
#[serde(default = "default_worker_max_document_bytes")]
|
||||||
pub default_tenant_slug: String,
|
pub worker_max_document_bytes: u64,
|
||||||
|
#[serde(default = "default_upload_body_limit_bytes")]
|
||||||
|
pub upload_body_limit_bytes: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub webauthn_rp_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub webauthn_origin: Option<String>,
|
||||||
|
#[serde(default = "default_webauthn_rp_name")]
|
||||||
|
pub webauthn_rp_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
@@ -67,7 +75,10 @@ impl AppConfig {
|
|||||||
database_url = %config.redacted_database_url(),
|
database_url = %config.redacted_database_url(),
|
||||||
pool_size = config.database_max_pool_size,
|
pool_size = config.database_max_pool_size,
|
||||||
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
||||||
|
passkeys_enabled = config.webauthn_origin.is_some(),
|
||||||
s3_bucket = %config.s3_bucket,
|
s3_bucket = %config.s3_bucket,
|
||||||
|
worker_max_document_bytes = config.worker_max_document_bytes,
|
||||||
|
upload_body_limit_bytes = config.upload_body_limit_bytes,
|
||||||
"loaded backend configuration"
|
"loaded backend configuration"
|
||||||
);
|
);
|
||||||
Ok(config)
|
Ok(config)
|
||||||
@@ -89,6 +100,22 @@ impl AppConfig {
|
|||||||
if self.webdav_host.is_empty() {
|
if self.webdav_host.is_empty() {
|
||||||
self.webdav_host = self.server_host.clone();
|
self.webdav_host = self.server_host.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.webauthn_rp_id.is_none() {
|
||||||
|
self.webauthn_rp_id = Some(self.server_host.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.webauthn_origin.is_none() {
|
||||||
|
let scheme = if self.server_host == "127.0.0.1" || self.server_host == "localhost" {
|
||||||
|
"http"
|
||||||
|
} else {
|
||||||
|
"https"
|
||||||
|
};
|
||||||
|
self.webauthn_origin = Some(format!(
|
||||||
|
"{scheme}://{}:{}",
|
||||||
|
self.server_host, self.server_port
|
||||||
|
));
|
||||||
|
}
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,8 +172,16 @@ fn default_aws_region() -> String {
|
|||||||
"us-east-1".to_string()
|
"us-east-1".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_tenant_slug() -> String {
|
fn default_worker_max_document_bytes() -> u64 {
|
||||||
"admin".to_string()
|
200 * 1024 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_upload_body_limit_bytes() -> u64 {
|
||||||
|
128 * 1024 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_webauthn_rp_name() -> String {
|
||||||
|
"Papercrate".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn redact_database_url(raw: &str) -> String {
|
fn redact_database_url(raw: &str) -> String {
|
||||||
|
|||||||
@@ -75,7 +75,10 @@ pub fn build_download_path(
|
|||||||
.jwt
|
.jwt
|
||||||
.generate_download_token(document.id, user_id, document.tenant_id)
|
.generate_download_token(document.id, user_id, document.tenant_id)
|
||||||
.map(|token| format!("/download/{token}"))
|
.map(|token| format!("/download/{token}"))
|
||||||
.map_err(|err| AppError::internal(format!("failed to generate download token: {err}")))
|
.map_err(|err| {
|
||||||
|
tracing::error!(error = ?err, "failed to generate download token");
|
||||||
|
AppError::internal("failed to generate download token")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
|
pub fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, bail, Result};
|
||||||
use reqwest::Client;
|
use reqwest::{Client, StatusCode};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::{Document, DocumentVersion};
|
||||||
|
|
||||||
pub const QUICKWIT_MAX_HITS: usize = 200;
|
pub const QUICKWIT_MAX_HITS: usize = 200;
|
||||||
|
|
||||||
pub fn build_quickwit_query(input: &str) -> Option<String> {
|
pub fn build_quickwit_query(input: &str) -> Option<String> {
|
||||||
@@ -107,6 +109,60 @@ pub async fn quickwit_search(
|
|||||||
Ok(doc_ids)
|
Ok(doc_ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn quickwit_index_template(index_id: &str) -> Value {
|
||||||
|
json!({
|
||||||
|
"version": "0.8",
|
||||||
|
"index_id": index_id,
|
||||||
|
"doc_mapping": {
|
||||||
|
"tokenizers": [
|
||||||
|
{
|
||||||
|
"name": "substring",
|
||||||
|
"type": "ngram",
|
||||||
|
"min_gram": 2,
|
||||||
|
"max_gram": 20,
|
||||||
|
"prefix_only": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"field_mappings": [
|
||||||
|
{ "name": "tenant_id", "type": "text", "stored": true },
|
||||||
|
{ "name": "document_id", "type": "text", "stored": true },
|
||||||
|
{ "name": "version_id", "type": "text", "stored": true },
|
||||||
|
{ "name": "title", "type": "text", "tokenizer": "substring", "stored": true },
|
||||||
|
{ "name": "text", "type": "text", "tokenizer": "substring", "record": "position" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"search_settings": {
|
||||||
|
"default_search_fields": ["title", "text"]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_quickwit_index(client: &Client, endpoint: &str, index_id: &str) -> Result<()> {
|
||||||
|
let base = endpoint.trim_end_matches('/');
|
||||||
|
let create_url = format!("{}/api/v1/indexes", base);
|
||||||
|
let payload = quickwit_index_template(index_id);
|
||||||
|
|
||||||
|
let response = client.post(&create_url).json(&payload).send().await?;
|
||||||
|
match response.status() {
|
||||||
|
status if status.is_success() => Ok(()),
|
||||||
|
StatusCode::CONFLICT => {
|
||||||
|
let lookup_url = format!("{}/api/v1/indexes/{}", base, index_id);
|
||||||
|
let lookup = client.get(&lookup_url).send().await?;
|
||||||
|
if lookup.status().is_success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let status = lookup.status();
|
||||||
|
let body = lookup.text().await.unwrap_or_default();
|
||||||
|
bail!("quickwit index lookup failed with status {status}: {body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status => {
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
bail!("quickwit create index failed with status {status}: {body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
|
pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
|
||||||
for key in ["_source", "source", "fields", "stored_fields"] {
|
for key in ["_source", "source", "fields", "stored_fields"] {
|
||||||
if let Some(value) = hit.get(key) {
|
if let Some(value) = hit.get(key) {
|
||||||
@@ -164,3 +220,71 @@ struct QuickwitSearchResponse {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
hits: Vec<Value>,
|
hits: Vec<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct QuickwitIngestRecord {
|
||||||
|
pub document_id: Uuid,
|
||||||
|
pub version_id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub title: String,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_quickwit_ingest_record(
|
||||||
|
document: &Document,
|
||||||
|
version: &DocumentVersion,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
text: &str,
|
||||||
|
) -> QuickwitIngestRecord {
|
||||||
|
QuickwitIngestRecord {
|
||||||
|
document_id: document.id,
|
||||||
|
version_id: version.id,
|
||||||
|
tenant_id,
|
||||||
|
title: document.title.to_lowercase(),
|
||||||
|
text: text.to_lowercase(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn quickwit_ingest(
|
||||||
|
client: &Client,
|
||||||
|
endpoint: &str,
|
||||||
|
index: &str,
|
||||||
|
records: &[QuickwitIngestRecord],
|
||||||
|
) -> Result<()> {
|
||||||
|
if records.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/v1/{}/ingest?commit=auto",
|
||||||
|
endpoint.trim_end_matches('/'),
|
||||||
|
index
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut body = String::new();
|
||||||
|
for record in records {
|
||||||
|
let line = serde_json::to_string(record)?;
|
||||||
|
body.push_str(&line);
|
||||||
|
body.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(%url, lines = records.len(), "sending quickwit ingest request");
|
||||||
|
let response = client
|
||||||
|
.post(url)
|
||||||
|
.header("content-type", "application/x-ndjson")
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
error!(%status, %body, "quickwit ingest request failed");
|
||||||
|
return Err(anyhow!(
|
||||||
|
"quickwit ingest failed with status {status}: {body}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("quickwit ingest request succeeded");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,7 +72,10 @@ impl From<diesel::result::Error> for AppError {
|
|||||||
fn from(value: diesel::result::Error) -> Self {
|
fn from(value: diesel::result::Error) -> Self {
|
||||||
match value {
|
match value {
|
||||||
diesel::result::Error::NotFound => AppError::not_found(),
|
diesel::result::Error::NotFound => AppError::not_found(),
|
||||||
_ => AppError::internal(value),
|
other => {
|
||||||
|
tracing::error!(error = ?other, "database operation failed");
|
||||||
|
AppError::internal("database operation failed")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub const JOB_ANALYZE_DOCUMENT: &str = "analyze-document";
|
|||||||
pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails";
|
pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails";
|
||||||
pub const JOB_GENERATE_OCR_TEXT: &str = "generate-ocr-text";
|
pub const JOB_GENERATE_OCR_TEXT: &str = "generate-ocr-text";
|
||||||
pub const JOB_INDEX_DOCUMENT_TEXT: &str = "index-document-text";
|
pub const JOB_INDEX_DOCUMENT_TEXT: &str = "index-document-text";
|
||||||
|
pub const JOB_PROVISION_TENANT: &str = "provision-tenant";
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum JobQueueError {
|
pub enum JobQueueError {
|
||||||
|
|||||||
+131
-7
@@ -1,7 +1,16 @@
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
|
use diesel::deserialize::FromSql;
|
||||||
|
use diesel::pg::{Pg, PgValue};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
use diesel::serialize::{IsNull, Output, ToSql};
|
||||||
|
use diesel::{deserialize, serialize, AsExpression, FromSqlRow};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fmt;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::str;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::schema::sql_types::TenantStatus as TenantStatusSql;
|
||||||
use crate::schema::*;
|
use crate::schema::*;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
@@ -12,7 +21,6 @@ pub struct UserMembership {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
pub role: String,
|
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
}
|
}
|
||||||
@@ -23,7 +31,65 @@ pub struct NewUserMembership {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
pub role: String,
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)]
|
||||||
|
#[diesel(sql_type = TenantStatusSql)]
|
||||||
|
pub enum TenantStatus {
|
||||||
|
Creating,
|
||||||
|
Active,
|
||||||
|
Suspended,
|
||||||
|
Deleting,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TenantStatus {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TenantStatus::Creating => "creating",
|
||||||
|
TenantStatus::Active => "active",
|
||||||
|
TenantStatus::Suspended => "suspended",
|
||||||
|
TenantStatus::Deleting => "deleting",
|
||||||
|
TenantStatus::Error => "error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_str(value: &str) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
"creating" => Some(TenantStatus::Creating),
|
||||||
|
"active" => Some(TenantStatus::Active),
|
||||||
|
"suspended" => Some(TenantStatus::Suspended),
|
||||||
|
"deleting" => Some(TenantStatus::Deleting),
|
||||||
|
"error" => Some(TenantStatus::Error),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for TenantStatus {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToSql<TenantStatusSql, Pg> for TenantStatus {
|
||||||
|
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<TenantStatusSql, Pg> for TenantStatus {
|
||||||
|
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
|
||||||
|
let value = str::from_utf8(bytes.as_bytes())
|
||||||
|
.map_err(|err| Box::<dyn std::error::Error + Send + Sync>::from(err))?;
|
||||||
|
TenantStatus::from_str(value).ok_or_else(|| {
|
||||||
|
Box::<dyn std::error::Error + Send + Sync>::from(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("invalid tenant status '{value}'"),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
@@ -31,13 +97,14 @@ pub struct NewUserMembership {
|
|||||||
#[diesel(primary_key(id))]
|
#[diesel(primary_key(id))]
|
||||||
pub struct Tenant {
|
pub struct Tenant {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub slug: String,
|
pub name: String,
|
||||||
pub storage_root: Option<String>,
|
pub storage_root: Option<String>,
|
||||||
pub quickwit_index: Option<String>,
|
pub quickwit_index: Option<String>,
|
||||||
pub status: String,
|
pub config: Value,
|
||||||
pub config: serde_json::Value,
|
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
|
pub status: TenantStatus,
|
||||||
|
pub created_by: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
@@ -45,7 +112,6 @@ pub struct Tenant {
|
|||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password_hash: String,
|
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
}
|
}
|
||||||
@@ -55,7 +121,65 @@ pub struct User {
|
|||||||
pub struct NewUser {
|
pub struct NewUser {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password_hash: String,
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations, Selectable)]
|
||||||
|
#[diesel(table_name = user_passkeys)]
|
||||||
|
#[diesel(belongs_to(User))]
|
||||||
|
pub struct UserPasskey {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub credential_id: Vec<u8>,
|
||||||
|
pub public_key: Vec<u8>,
|
||||||
|
pub credential: serde_json::Value,
|
||||||
|
pub sign_count: i64,
|
||||||
|
pub transports: Vec<Option<String>>,
|
||||||
|
pub aaguid: Option<Uuid>,
|
||||||
|
pub nickname: Option<String>,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
pub last_used_at: Option<NaiveDateTime>,
|
||||||
|
pub revoked_at: Option<NaiveDateTime>,
|
||||||
|
pub revoked_by: Option<Uuid>,
|
||||||
|
pub revoked_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Insertable)]
|
||||||
|
#[diesel(table_name = user_passkeys)]
|
||||||
|
pub struct NewUserPasskey {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub credential_id: Vec<u8>,
|
||||||
|
pub public_key: Vec<u8>,
|
||||||
|
pub credential: serde_json::Value,
|
||||||
|
pub sign_count: i64,
|
||||||
|
pub transports: Vec<Option<String>>,
|
||||||
|
pub aaguid: Option<Uuid>,
|
||||||
|
pub nickname: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
|
#[diesel(table_name = webauthn_challenges)]
|
||||||
|
#[diesel(belongs_to(User))]
|
||||||
|
pub struct WebauthnChallenge {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
|
pub purpose: String,
|
||||||
|
pub challenge: Vec<u8>,
|
||||||
|
pub state: Vec<u8>,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub expires_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Insertable)]
|
||||||
|
#[diesel(table_name = webauthn_challenges)]
|
||||||
|
pub struct NewWebauthnChallenge {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
|
pub purpose: String,
|
||||||
|
pub challenge: Vec<u8>,
|
||||||
|
pub state: Vec<u8>,
|
||||||
|
pub expires_at: NaiveDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
|
|||||||
+132
-3
@@ -8,11 +8,17 @@ use uuid::Uuid;
|
|||||||
paths(
|
paths(
|
||||||
doc::health_check,
|
doc::health_check,
|
||||||
doc::login,
|
doc::login,
|
||||||
|
doc::signup_start,
|
||||||
|
doc::signup_finish,
|
||||||
doc::refresh,
|
doc::refresh,
|
||||||
doc::logout,
|
doc::logout,
|
||||||
doc::me,
|
doc::me,
|
||||||
doc::list_tenants,
|
doc::list_tenants,
|
||||||
doc::select_tenant,
|
doc::select_tenant,
|
||||||
|
doc::passkey_register_start,
|
||||||
|
doc::passkey_register_finish,
|
||||||
|
doc::passkey_login_start,
|
||||||
|
doc::passkey_login_finish,
|
||||||
doc::list_documents,
|
doc::list_documents,
|
||||||
doc::check_document,
|
doc::check_document,
|
||||||
doc::upload_document,
|
doc::upload_document,
|
||||||
@@ -49,16 +55,27 @@ use uuid::Uuid;
|
|||||||
doc::list_webdav_tokens,
|
doc::list_webdav_tokens,
|
||||||
doc::create_webdav_token,
|
doc::create_webdav_token,
|
||||||
doc::delete_webdav_token,
|
doc::delete_webdav_token,
|
||||||
|
doc::list_passkeys,
|
||||||
|
doc::delete_passkey,
|
||||||
),
|
),
|
||||||
components(
|
components(
|
||||||
schemas(
|
schemas(
|
||||||
schemas::LoginRequest,
|
schemas::LoginRequest,
|
||||||
|
schemas::SignupStartRequest,
|
||||||
|
schemas::SignupStartResponse,
|
||||||
|
schemas::SignupFinishRequest,
|
||||||
schemas::AccessTokenResponse,
|
schemas::AccessTokenResponse,
|
||||||
schemas::TenantSnippet,
|
schemas::TenantSnippet,
|
||||||
schemas::TenantSelectionResponse,
|
schemas::TenantSelectionResponse,
|
||||||
schemas::TenantSelectionRequest,
|
schemas::TenantSelectionRequest,
|
||||||
schemas::TenantListResponse,
|
schemas::TenantListResponse,
|
||||||
schemas::LoginResponseVariants,
|
schemas::LoginResponseVariants,
|
||||||
|
schemas::RegistrationChallengeResponse,
|
||||||
|
schemas::AuthenticationChallengeResponse,
|
||||||
|
schemas::PasskeySummary,
|
||||||
|
schemas::PasskeyRegistrationFinishPayload,
|
||||||
|
schemas::PasskeyLoginStartPayload,
|
||||||
|
schemas::PasskeyLoginFinishPayload,
|
||||||
schemas::DocumentResponse,
|
schemas::DocumentResponse,
|
||||||
schemas::DocumentDetailResponse,
|
schemas::DocumentDetailResponse,
|
||||||
schemas::DocumentVersionResponse,
|
schemas::DocumentVersionResponse,
|
||||||
@@ -151,6 +168,32 @@ mod doc {
|
|||||||
)]
|
)]
|
||||||
pub(super) fn login() {}
|
pub(super) fn login() {}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/auth/signup/start",
|
||||||
|
request_body = SignupStartRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Signup challenge created", body = SignupStartResponse),
|
||||||
|
(status = 400, description = "Invalid signup request"),
|
||||||
|
(status = 409, description = "Username already exists")
|
||||||
|
),
|
||||||
|
tag = "Auth"
|
||||||
|
)]
|
||||||
|
pub(super) fn signup_start() {}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/auth/signup/finish",
|
||||||
|
request_body = SignupFinishRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Signup completed", body = LoginResponseVariants),
|
||||||
|
(status = 400, description = "Invalid signup completion"),
|
||||||
|
(status = 409, description = "Username already exists")
|
||||||
|
),
|
||||||
|
tag = "Auth"
|
||||||
|
)]
|
||||||
|
pub(super) fn signup_finish() {}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/auth/refresh",
|
path = "/api/auth/refresh",
|
||||||
@@ -195,6 +238,44 @@ mod doc {
|
|||||||
)]
|
)]
|
||||||
pub(super) fn select_tenant() {}
|
pub(super) fn select_tenant() {}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/auth/passkeys/register/start",
|
||||||
|
responses((status = 200, description = "Passkey registration challenge", body = RegistrationChallengeResponse)),
|
||||||
|
tag = "Auth"
|
||||||
|
)]
|
||||||
|
pub(super) fn passkey_register_start() {}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/auth/passkeys/register/finish",
|
||||||
|
request_body = PasskeyRegistrationFinishPayload,
|
||||||
|
responses((status = 200, description = "Passkey registered", body = PasskeySummary)),
|
||||||
|
tag = "Auth"
|
||||||
|
)]
|
||||||
|
pub(super) fn passkey_register_finish() {}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/auth/passkeys/login/start",
|
||||||
|
request_body = PasskeyLoginStartPayload,
|
||||||
|
responses((status = 200, description = "Passkey authentication challenge", body = AuthenticationChallengeResponse)),
|
||||||
|
tag = "Auth"
|
||||||
|
)]
|
||||||
|
pub(super) fn passkey_login_start() {}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/auth/passkeys/login/finish",
|
||||||
|
request_body = PasskeyLoginFinishPayload,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Passkey login successful", body = LoginResponseVariants),
|
||||||
|
(status = 401, description = "Authentication failed")
|
||||||
|
),
|
||||||
|
tag = "Auth"
|
||||||
|
)]
|
||||||
|
pub(super) fn passkey_login_finish() {}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/documents",
|
path = "/api/documents",
|
||||||
@@ -421,7 +502,10 @@ mod doc {
|
|||||||
post,
|
post,
|
||||||
path = "/api/folders",
|
path = "/api/folders",
|
||||||
request_body = CreateFolderRequest,
|
request_body = CreateFolderRequest,
|
||||||
responses((status = 200, description = "Folder created", body = FolderResponse)),
|
responses(
|
||||||
|
(status = 201, description = "Folder created", body = FolderResponse),
|
||||||
|
(status = 200, description = "Folder already existed", body = FolderResponse)
|
||||||
|
),
|
||||||
tag = "Folders"
|
tag = "Folders"
|
||||||
)]
|
)]
|
||||||
pub(super) fn create_folder() {}
|
pub(super) fn create_folder() {}
|
||||||
@@ -572,6 +656,26 @@ mod doc {
|
|||||||
tag = "Profile"
|
tag = "Profile"
|
||||||
)]
|
)]
|
||||||
pub(super) fn delete_webdav_token() {}
|
pub(super) fn delete_webdav_token() {}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/profile/passkeys",
|
||||||
|
responses((status = 200, description = "List registered passkeys", body = [PasskeySummary])),
|
||||||
|
tag = "Profile"
|
||||||
|
)]
|
||||||
|
pub(super) fn list_passkeys() {}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/profile/passkeys/{id}",
|
||||||
|
params(
|
||||||
|
("id" = Uuid, Path, description = "Passkey ID"),
|
||||||
|
("reason" = Option<String>, Query, description = "Optional reason for revoking the passkey")
|
||||||
|
),
|
||||||
|
responses((status = 204, description = "Passkey revoked")),
|
||||||
|
tag = "Profile"
|
||||||
|
)]
|
||||||
|
pub(super) fn delete_passkey() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -589,12 +693,37 @@ mod tests {
|
|||||||
pub mod schemas {
|
pub mod schemas {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
pub use crate::auth::passkeys::{
|
||||||
|
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
||||||
|
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
||||||
|
};
|
||||||
|
use webauthn_rs::prelude::RegisterPublicKeyCredential;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct LoginRequest {
|
pub struct LoginRequest {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub preferred_tenant_slug: Option<String>,
|
pub preferred_tenant_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct SignupStartRequest {
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct SignupStartResponse {
|
||||||
|
pub signup_token: String,
|
||||||
|
pub challenge: RegistrationChallengeResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct SignupFinishRequest {
|
||||||
|
pub signup_token: String,
|
||||||
|
pub credential: RegisterPublicKeyCredential,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub nickname: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
@@ -608,7 +737,7 @@ pub mod schemas {
|
|||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct TenantSnippet {
|
pub struct TenantSnippet {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub slug: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
|||||||
+272
-71
@@ -16,17 +16,24 @@ use sha2::{Digest, Sha256};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::{password, AuthenticatedUser},
|
auth::{
|
||||||
|
passkeys::{
|
||||||
|
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
||||||
|
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
||||||
|
},
|
||||||
|
AuthenticatedUser,
|
||||||
|
},
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::{NewRefreshToken, RefreshToken, Tenant, User, UserMembership},
|
models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership},
|
||||||
schema::{
|
schema::{
|
||||||
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
|
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
|
||||||
users::dsl,
|
user_passkeys::dsl as passkey_dsl, users::dsl,
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::schema::refresh_tokens::dsl as refresh_dsl;
|
use crate::schema::refresh_tokens::dsl as refresh_dsl;
|
||||||
|
use webauthn_rs::prelude::RegisterPublicKeyCredential;
|
||||||
|
|
||||||
const REFRESH_COOKIE_NAME: &str = "refresh_token";
|
const REFRESH_COOKIE_NAME: &str = "refresh_token";
|
||||||
|
|
||||||
@@ -35,10 +42,10 @@ pub struct LoginRequest {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub preferred_tenant_slug: Option<String>,
|
pub preferred_tenant_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
pub struct LoginResponse {
|
pub struct LoginResponse {
|
||||||
pub access_token: String,
|
pub access_token: String,
|
||||||
pub token_type: String,
|
pub token_type: String,
|
||||||
@@ -46,10 +53,10 @@ pub struct LoginResponse {
|
|||||||
pub tenant: TenantSnippet,
|
pub tenant: TenantSnippet,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct TenantSnippet {
|
pub struct TenantSnippet {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub slug: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -68,75 +75,120 @@ pub struct TenantSelectionRequest {
|
|||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login(
|
#[derive(Deserialize)]
|
||||||
|
pub struct SignupStartRequest {
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SignupStartResponse {
|
||||||
|
pub signup_token: String,
|
||||||
|
pub challenge: RegistrationChallengeResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SignupFinishRequest {
|
||||||
|
pub signup_token: String,
|
||||||
|
pub credential: RegisterPublicKeyCredential,
|
||||||
|
pub nickname: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 signup_start(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<LoginRequest>,
|
Json(payload): Json<SignupStartRequest>,
|
||||||
) -> AppResult<Response> {
|
) -> AppResult<Json<SignupStartResponse>> {
|
||||||
|
let username = payload.username.trim();
|
||||||
|
if username.is_empty() {
|
||||||
|
return Err(AppError::bad_request("username must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
let mut conn = state.db_unscoped()?;
|
let mut conn = state.db_unscoped()?;
|
||||||
|
let exists: bool = dsl::users
|
||||||
let user: Option<User> = dsl::users
|
.filter(dsl::username.eq(username))
|
||||||
.filter(dsl::username.eq(&payload.username))
|
.first::<User>(&mut conn)
|
||||||
.first(&mut conn)
|
.optional()?
|
||||||
.optional()?;
|
.is_some();
|
||||||
|
if exists {
|
||||||
let user = match user {
|
return Err(AppError::conflict("username already exists"));
|
||||||
Some(user) => user,
|
|
||||||
None => return Err(AppError::unauthorized()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let valid = password::verify_password(&payload.password, &user.password_hash)
|
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
|
||||||
|
|
||||||
if !valid {
|
|
||||||
return Err(AppError::unauthorized());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let memberships: Vec<(UserMembership, Tenant)> = memberships_dsl::user_memberships
|
let user_id = Uuid::new_v4();
|
||||||
.inner_join(tenant_dsl::tenants)
|
let challenge = state
|
||||||
.filter(memberships_dsl::user_id.eq(user.id))
|
.passkeys
|
||||||
.load(&mut conn)?;
|
|
||||||
|
|
||||||
if memberships.is_empty() {
|
|
||||||
return Err(AppError::unauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
let preferred_slug = payload
|
|
||||||
.preferred_tenant_slug
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|slug| slug.trim().to_string())
|
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?
|
||||||
.filter(|slug| !slug.is_empty());
|
.start_signup_registration(&mut conn, user_id, username)?;
|
||||||
|
|
||||||
if let Some(tenant) = preferred_slug.as_ref().and_then(|slug| {
|
let signup_token = state
|
||||||
memberships
|
|
||||||
.iter()
|
|
||||||
.find(|(_, tenant)| tenant.slug.eq_ignore_ascii_case(slug))
|
|
||||||
}) {
|
|
||||||
return issue_session(&state, &mut conn, &user, tenant.1.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if memberships.len() == 1 {
|
|
||||||
let tenant_id = memberships[0].1.id;
|
|
||||||
return issue_session(&state, &mut conn, &user, tenant_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
let selection_token = state
|
|
||||||
.jwt
|
.jwt
|
||||||
.generate_tenant_selector_token(user.id)
|
.generate_signup_token(user_id, challenge.challenge_id, username.to_owned())
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
let tenants = memberships
|
Ok(Json(SignupStartResponse {
|
||||||
.into_iter()
|
signup_token,
|
||||||
.map(|(_, tenant)| TenantSnippet {
|
challenge,
|
||||||
id: tenant.id,
|
}))
|
||||||
slug: tenant.slug,
|
}
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let response = Json(TenantSelectionResponse {
|
pub async fn signup_finish(
|
||||||
access_token: selection_token,
|
State(state): State<AppState>,
|
||||||
tenants,
|
Json(payload): Json<SignupFinishRequest>,
|
||||||
})
|
) -> AppResult<Response> {
|
||||||
.into_response();
|
let claims = state
|
||||||
|
.jwt
|
||||||
|
.verify_signup_token(&payload.signup_token)
|
||||||
|
.map_err(|_| AppError::unauthorized())?;
|
||||||
|
|
||||||
|
let mut conn = state.db_unscoped()?;
|
||||||
|
|
||||||
|
let exists: bool = dsl::users
|
||||||
|
.filter(dsl::username.eq(&claims.username))
|
||||||
|
.first::<User>(&mut conn)
|
||||||
|
.optional()?
|
||||||
|
.is_some();
|
||||||
|
if exists {
|
||||||
|
return Err(AppError::conflict("username already exists"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let service = state
|
||||||
|
.passkeys
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||||
|
|
||||||
|
let prepared_passkey =
|
||||||
|
service.consume_signup_challenge(&mut conn, claims.challenge_id, &payload.credential)?;
|
||||||
|
|
||||||
|
let state_clone = state.clone();
|
||||||
|
let response = conn.transaction::<Response, AppError, _>(|conn| {
|
||||||
|
insert_user(conn, claims.sub, &claims.username)?;
|
||||||
|
|
||||||
|
let tenant = state_clone.tenants.create_tenant_with_conn(
|
||||||
|
conn,
|
||||||
|
&claims.username,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
TenantStatus::Creating,
|
||||||
|
&[claims.sub],
|
||||||
|
Some(claims.sub),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let passkey_insert =
|
||||||
|
prepared_passkey.into_new_user_passkey(claims.sub, payload.nickname.clone());
|
||||||
|
|
||||||
|
diesel::insert_into(passkey_dsl::user_passkeys)
|
||||||
|
.values(&passkey_insert)
|
||||||
|
.execute(conn)
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
let user: User = dsl::users.find(claims.sub).first(conn)?;
|
||||||
|
issue_session(&state_clone, conn, &user, tenant.id)
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
@@ -181,6 +233,19 @@ pub async fn refresh(
|
|||||||
issue_session(&state, &mut conn, &user, token.tenant_id)
|
issue_session(&state, &mut conn, &user, token.tenant_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> {
|
||||||
|
let new_user = NewUser {
|
||||||
|
id,
|
||||||
|
username: username.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::insert_into(dsl::users)
|
||||||
|
.values(&new_user)
|
||||||
|
.execute(conn)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(AppError::from)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn select_tenant(
|
pub async fn select_tenant(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
||||||
@@ -289,15 +354,151 @@ pub async fn list_tenants(
|
|||||||
let tenants = memberships_dsl::user_memberships
|
let tenants = memberships_dsl::user_memberships
|
||||||
.inner_join(tenant_dsl::tenants)
|
.inner_join(tenant_dsl::tenants)
|
||||||
.filter(memberships_dsl::user_id.eq(user_id))
|
.filter(memberships_dsl::user_id.eq(user_id))
|
||||||
.select((tenant_dsl::id, tenant_dsl::slug))
|
.select((tenant_dsl::id, tenant_dsl::name))
|
||||||
.load::<(Uuid, String)>(&mut conn)?
|
.load::<(Uuid, String)>(&mut conn)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, slug)| TenantSnippet { id, slug })
|
.map(|(id, name)| TenantSnippet { id, name })
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(Json(TenantListResponse { tenants }))
|
Ok(Json(TenantListResponse { tenants }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn passkey_register_start(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
) -> AppResult<Json<RegistrationChallengeResponse>> {
|
||||||
|
let service = state
|
||||||
|
.passkeys
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||||
|
|
||||||
|
let mut conn = state.db_unscoped()?;
|
||||||
|
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
|
||||||
|
let challenge = service.start_registration(&mut conn, ¤t_user)?;
|
||||||
|
Ok(Json(challenge))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn passkey_register_finish(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
Json(payload): Json<PasskeyRegistrationFinishPayload>,
|
||||||
|
) -> AppResult<Json<PasskeySummary>> {
|
||||||
|
let service = state
|
||||||
|
.passkeys
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||||
|
|
||||||
|
let mut conn = state.db_unscoped()?;
|
||||||
|
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
|
||||||
|
|
||||||
|
let PasskeyRegistrationFinishPayload {
|
||||||
|
challenge_id,
|
||||||
|
credential,
|
||||||
|
nickname,
|
||||||
|
} = payload;
|
||||||
|
|
||||||
|
let passkey = service.finish_registration(
|
||||||
|
&mut conn,
|
||||||
|
¤t_user,
|
||||||
|
challenge_id,
|
||||||
|
credential,
|
||||||
|
nickname,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(Json(PasskeySummary::from(passkey)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn passkey_login_start(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<PasskeyLoginStartPayload>,
|
||||||
|
) -> AppResult<Json<AuthenticationChallengeResponse>> {
|
||||||
|
let service = state
|
||||||
|
.passkeys
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||||
|
|
||||||
|
let username = payload.username.trim();
|
||||||
|
if username.is_empty() {
|
||||||
|
return Err(AppError::bad_request("username must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut conn = state.db_unscoped()?;
|
||||||
|
let user: User = dsl::users
|
||||||
|
.filter(dsl::username.eq(username))
|
||||||
|
.first(&mut conn)?;
|
||||||
|
|
||||||
|
let challenge = service.start_authentication(&mut conn, &user)?;
|
||||||
|
Ok(Json(challenge))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn passkey_login_finish(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<PasskeyLoginFinishPayload>,
|
||||||
|
) -> AppResult<Response> {
|
||||||
|
let service = state
|
||||||
|
.passkeys
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||||
|
|
||||||
|
let mut conn = state.db_unscoped()?;
|
||||||
|
let (user, _passkey, auth_result) =
|
||||||
|
service.finish_authentication(&mut conn, payload.challenge_id, payload.credential)?;
|
||||||
|
|
||||||
|
if !auth_result.user_verified() {
|
||||||
|
return Err(AppError::unauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
complete_login(&state, &mut conn, &user, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn complete_login(
|
||||||
|
state: &AppState,
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
user: &User,
|
||||||
|
preferred_tenant_id: Option<Uuid>,
|
||||||
|
) -> AppResult<Response> {
|
||||||
|
let memberships: Vec<(UserMembership, Tenant)> = memberships_dsl::user_memberships
|
||||||
|
.inner_join(tenant_dsl::tenants)
|
||||||
|
.filter(memberships_dsl::user_id.eq(user.id))
|
||||||
|
.load(conn)?;
|
||||||
|
|
||||||
|
if memberships.is_empty() {
|
||||||
|
return Err(AppError::unauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(preferred_id) = preferred_tenant_id {
|
||||||
|
if let Some((_, tenant)) = memberships
|
||||||
|
.iter()
|
||||||
|
.find(|(_, tenant)| tenant.id == preferred_id)
|
||||||
|
{
|
||||||
|
return issue_session(state, conn, user, tenant.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if memberships.len() == 1 {
|
||||||
|
return issue_session(state, conn, user, memberships[0].1.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let selection_token = state
|
||||||
|
.jwt
|
||||||
|
.generate_tenant_selector_token(user.id)
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
let tenants = memberships
|
||||||
|
.into_iter()
|
||||||
|
.map(|(_, tenant)| TenantSnippet {
|
||||||
|
id: tenant.id,
|
||||||
|
name: tenant.name,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(TenantSelectionResponse {
|
||||||
|
access_token: selection_token,
|
||||||
|
tenants,
|
||||||
|
})
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
fn issue_session(
|
fn issue_session(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
@@ -310,9 +511,9 @@ fn issue_session(
|
|||||||
.generate_token(user.id, tenant_id, &user.username)
|
.generate_token(user.id, tenant_id, &user.username)
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
let tenant_slug: String = tenant_dsl::tenants
|
let tenant_name: String = tenant_dsl::tenants
|
||||||
.find(tenant_id)
|
.find(tenant_id)
|
||||||
.select(tenant_dsl::slug)
|
.select(tenant_dsl::name)
|
||||||
.first(conn)
|
.first(conn)
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
@@ -339,7 +540,7 @@ fn issue_session(
|
|||||||
expires_in: state.config.jwt_expiry_minutes * 60,
|
expires_in: state.config.jwt_expiry_minutes * 60,
|
||||||
tenant: TenantSnippet {
|
tenant: TenantSnippet {
|
||||||
id: tenant_id,
|
id: tenant_id,
|
||||||
slug: tenant_slug,
|
name: tenant_name,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.into_response();
|
.into_response();
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ use crate::schema::{
|
|||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::utils::{
|
use crate::utils::{
|
||||||
db::{no_content, validate_bulk_ids, IntoJsonResponse},
|
db::{no_content, validate_bulk_ids, IntoJsonResponse},
|
||||||
|
error::StorageResultExt,
|
||||||
http::inline_content_disposition,
|
http::inline_content_disposition,
|
||||||
json::{classify_nullable, NullableValue},
|
json::{classify_nullable, NullableValue},
|
||||||
storage_paths::document_version_object_key,
|
storage_paths::document_version_object_key,
|
||||||
@@ -412,7 +413,10 @@ pub async fn list_documents(
|
|||||||
|
|
||||||
let ids = quickwit_search(endpoint, index, tenant_id, query_str)
|
let ids = quickwit_search(endpoint, index, tenant_id, query_str)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::internal(format!("quickwit search failed: {err}")))?;
|
.map_err(|err| {
|
||||||
|
error!(error = ?err, "quickwit search failed");
|
||||||
|
AppError::internal("quickwit search failed")
|
||||||
|
})?;
|
||||||
|
|
||||||
if ids.is_empty() {
|
if ids.is_empty() {
|
||||||
return Ok(Json(vec![]));
|
return Ok(Json(vec![]));
|
||||||
@@ -913,7 +917,10 @@ pub async fn request_document_assets(
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.map_err(|err| AppError::internal(format!("failed to enqueue analyze job: {err}")))?;
|
.map_err(|err| {
|
||||||
|
error!(error = ?err, "failed to enqueue analyze job");
|
||||||
|
AppError::internal("failed to enqueue analyze job")
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(StatusCode::ACCEPTED)
|
Ok(StatusCode::ACCEPTED)
|
||||||
}
|
}
|
||||||
@@ -959,7 +966,10 @@ pub async fn reanalyze_selected_documents(
|
|||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.map_err(|err| AppError::internal(format!("failed to enqueue analyze job: {err}")))?;
|
.map_err(|err| {
|
||||||
|
error!(error = ?err, "failed to enqueue analyze job");
|
||||||
|
AppError::internal("failed to enqueue analyze job")
|
||||||
|
})?;
|
||||||
queued += 1;
|
queued += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1048,7 +1058,7 @@ pub async fn get_document_asset(
|
|||||||
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::internal(format!("failed to generate asset URL: {err}")))?;
|
.storage_context("failed to generate asset URL")?;
|
||||||
|
|
||||||
object_responses.push(to_asset_object_response(
|
object_responses.push(to_asset_object_response(
|
||||||
object,
|
object,
|
||||||
@@ -1178,7 +1188,7 @@ pub async fn download_with_token(
|
|||||||
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::internal(format!("failed to generate download URL: {err}")))?;
|
.storage_context("failed to generate download URL")?;
|
||||||
|
|
||||||
Ok(axum::response::Redirect::temporary(&presigned_url))
|
Ok(axum::response::Redirect::temporary(&presigned_url))
|
||||||
}
|
}
|
||||||
@@ -2035,10 +2045,7 @@ async fn process_upload(
|
|||||||
content_disposition.clone(),
|
content_disposition.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| {
|
.storage_context("failed to store document")?;
|
||||||
error!(error = %err, key = %s3_key, "failed to store document");
|
|
||||||
AppError::internal(format!("failed to store document: {err}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let metadata_value = if metadata.is_null() {
|
let metadata_value = if metadata.is_null() {
|
||||||
Value::Object(Default::default())
|
Value::Object(Default::default())
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ pub async fn ensure_folder_path(
|
|||||||
last_folder = Some(folder);
|
last_folder = Some(folder);
|
||||||
}
|
}
|
||||||
|
|
||||||
last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path".to_string()))
|
last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Json(FolderResponse {
|
Ok(Json(FolderResponse {
|
||||||
@@ -179,7 +179,7 @@ pub async fn create_folder(
|
|||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
Json(payload): Json<CreateFolderRequest>,
|
Json(payload): Json<CreateFolderRequest>,
|
||||||
) -> AppResult<Json<FolderResponse>> {
|
) -> AppResult<(StatusCode, Json<FolderResponse>)> {
|
||||||
if payload.name.trim().is_empty() {
|
if payload.name.trim().is_empty() {
|
||||||
return Err(AppError::bad_request("name must not be empty"));
|
return Err(AppError::bad_request("name must not be empty"));
|
||||||
}
|
}
|
||||||
@@ -202,8 +202,8 @@ pub async fn create_folder(
|
|||||||
.optional()?
|
.optional()?
|
||||||
};
|
};
|
||||||
|
|
||||||
let folder: Folder = if let Some(folder) = existing {
|
let (folder, created): (Folder, bool) = if let Some(folder) = existing {
|
||||||
folder
|
(folder, false)
|
||||||
} else {
|
} else {
|
||||||
let new_folder = NewFolder {
|
let new_folder = NewFolder {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
@@ -220,28 +220,43 @@ pub async fn create_folder(
|
|||||||
.optional()?;
|
.optional()?;
|
||||||
|
|
||||||
if let Some(id) = inserted_id {
|
if let Some(id) = inserted_id {
|
||||||
folders::table
|
(
|
||||||
.find(id)
|
folders::table
|
||||||
.filter(folders::tenant_id.eq(tenant_id))
|
.find(id)
|
||||||
.first(&mut conn)?
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.first(&mut conn)?,
|
||||||
|
true,
|
||||||
|
)
|
||||||
} else if let Some(parent_id) = payload.parent_id {
|
} else if let Some(parent_id) = payload.parent_id {
|
||||||
folders::table
|
(
|
||||||
.filter(folders::tenant_id.eq(tenant_id))
|
folders::table
|
||||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
.filter(folders::name.eq(name))
|
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||||
.first(&mut conn)?
|
.filter(folders::name.eq(name))
|
||||||
|
.first(&mut conn)?,
|
||||||
|
false,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
folders::table
|
(
|
||||||
.filter(folders::tenant_id.eq(tenant_id))
|
folders::table
|
||||||
.filter(folders::parent_id.is_null())
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
.filter(folders::name.eq(name))
|
.filter(folders::parent_id.is_null())
|
||||||
.first(&mut conn)?
|
.filter(folders::name.eq(name))
|
||||||
|
.first(&mut conn)?,
|
||||||
|
false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(FolderResponse {
|
let response = Json(FolderResponse {
|
||||||
folder: folder_to_info(folder),
|
folder: folder_to_info(folder),
|
||||||
}))
|
});
|
||||||
|
|
||||||
|
if created {
|
||||||
|
Ok((StatusCode::CREATED, response))
|
||||||
|
} else {
|
||||||
|
Ok((StatusCode::OK, response))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_folder_contents(
|
pub async fn list_folder_contents(
|
||||||
|
|||||||
@@ -54,11 +54,23 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let auth_routes = Router::new()
|
let auth_routes = Router::new()
|
||||||
|
.route("/signup/start", post(auth::signup_start))
|
||||||
|
.route("/signup/finish", post(auth::signup_finish))
|
||||||
.route("/login", post(auth::login))
|
.route("/login", post(auth::login))
|
||||||
.route("/refresh", post(auth::refresh))
|
.route("/refresh", post(auth::refresh))
|
||||||
.route("/logout", post(auth::logout))
|
.route("/logout", post(auth::logout))
|
||||||
.route("/select-tenant", post(auth::select_tenant))
|
.route("/select-tenant", post(auth::select_tenant))
|
||||||
.route("/tenants", get(auth::list_tenants))
|
.route("/tenants", get(auth::list_tenants))
|
||||||
|
.route(
|
||||||
|
"/passkeys/register/start",
|
||||||
|
post(auth::passkey_register_start),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/passkeys/register/finish",
|
||||||
|
post(auth::passkey_register_finish),
|
||||||
|
)
|
||||||
|
.route("/passkeys/login/start", post(auth::passkey_login_start))
|
||||||
|
.route("/passkeys/login/finish", post(auth::passkey_login_finish))
|
||||||
.route("/me", get(auth::me));
|
.route("/me", get(auth::me));
|
||||||
|
|
||||||
let documents_routes = Router::new()
|
let documents_routes = Router::new()
|
||||||
@@ -136,7 +148,9 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
"/webdav-tokens",
|
"/webdav-tokens",
|
||||||
get(profile::list_webdav_tokens).post(profile::create_webdav_token),
|
get(profile::list_webdav_tokens).post(profile::create_webdav_token),
|
||||||
)
|
)
|
||||||
.route("/webdav-tokens/:id", delete(profile::delete_webdav_token));
|
.route("/webdav-tokens/:id", delete(profile::delete_webdav_token))
|
||||||
|
.route("/passkeys", get(profile::list_passkeys))
|
||||||
|
.route("/passkeys/:id", delete(profile::delete_passkey));
|
||||||
|
|
||||||
let protected_state = state.clone();
|
let protected_state = state.clone();
|
||||||
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
|
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
|
||||||
@@ -162,6 +176,8 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let upload_limit = state.config.upload_body_limit_bytes;
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(download_routes)
|
.merge(download_routes)
|
||||||
.merge(protected_routes)
|
.merge(protected_routes)
|
||||||
@@ -170,7 +186,9 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.route("/api/health", get(health::health_check))
|
.route("/api/health", get(health::health_check))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.layer(DefaultBodyLimit::max(1024 * 1024 * 512))
|
.layer(DefaultBodyLimit::max(
|
||||||
|
usize::try_from(upload_limit).unwrap_or(usize::MAX),
|
||||||
|
))
|
||||||
.layer(
|
.layer(
|
||||||
TraceLayer::new_for_http()
|
TraceLayer::new_for_http()
|
||||||
.make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO))
|
.make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO))
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
use axum::{extract::Path, http::StatusCode, Json};
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
use chrono::{DateTime, NaiveDateTime};
|
use chrono::{DateTime, NaiveDateTime};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::{
|
use crate::auth::{
|
||||||
|
passkeys::PasskeySummary,
|
||||||
webdav_tokens::{
|
webdav_tokens::{
|
||||||
create_webdav_token as issue_token, list_webdav_tokens as load_tokens,
|
create_webdav_token as issue_token, list_webdav_tokens as load_tokens,
|
||||||
revoke_webdav_token as revoke_token,
|
revoke_webdav_token as revoke_token,
|
||||||
@@ -12,6 +17,7 @@ use crate::auth::{
|
|||||||
};
|
};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::models::WebdavToken;
|
use crate::models::WebdavToken;
|
||||||
|
use crate::state::AppState;
|
||||||
use crate::utils::{db::no_content, time::to_iso};
|
use crate::utils::{db::no_content, time::to_iso};
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -37,6 +43,27 @@ pub struct CreateWebdavTokenRequest {
|
|||||||
pub expires_at: Option<String>,
|
pub expires_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RevokePasskeyQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_passkeys(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
TenantScopedConn {
|
||||||
|
mut conn, user_id, ..
|
||||||
|
}: TenantScopedConn,
|
||||||
|
) -> AppResult<Json<Vec<PasskeySummary>>> {
|
||||||
|
let service = state
|
||||||
|
.passkeys
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||||
|
|
||||||
|
let passkeys = service.list_for_user(&mut conn, user_id)?;
|
||||||
|
Ok(Json(passkeys))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_webdav_tokens(
|
pub async fn list_webdav_tokens(
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn,
|
mut conn,
|
||||||
@@ -90,6 +117,30 @@ pub async fn delete_webdav_token(
|
|||||||
no_content()
|
no_content()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_passkey(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
TenantScopedConn {
|
||||||
|
mut conn, user_id, ..
|
||||||
|
}: TenantScopedConn,
|
||||||
|
Path(passkey_id): Path<Uuid>,
|
||||||
|
Query(query): Query<RevokePasskeyQuery>,
|
||||||
|
) -> AppResult<StatusCode> {
|
||||||
|
let service = state
|
||||||
|
.passkeys
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||||
|
|
||||||
|
let active_count = service.active_passkey_count(&mut conn, user_id)?;
|
||||||
|
if active_count <= 1 {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"cannot revoke the last remaining passkey",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
service.revoke_passkey(&mut conn, user_id, passkey_id, query.reason)?;
|
||||||
|
no_content()
|
||||||
|
}
|
||||||
|
|
||||||
fn webdav_token_to_response(token: WebdavToken) -> WebdavTokenResponse {
|
fn webdav_token_to_response(token: WebdavToken) -> WebdavTokenResponse {
|
||||||
WebdavTokenResponse {
|
WebdavTokenResponse {
|
||||||
id: token.id,
|
id: token.id,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ use crate::schema::{
|
|||||||
user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
|
user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::utils::{http::inline_content_disposition, time::to_http_date};
|
use crate::utils::{error::StorageResultExt, http::inline_content_disposition, time::to_http_date};
|
||||||
|
|
||||||
const REALM: &str = "Papercrate WebDAV";
|
const REALM: &str = "Papercrate WebDAV";
|
||||||
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
|
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
|
||||||
@@ -108,8 +108,10 @@ async fn handle_propfind(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let body = render_multistatus(&resources)
|
let body = render_multistatus(&resources).map_err(|err| {
|
||||||
.map_err(|err| AppError::internal(format!("failed to render WebDAV response: {err}")))?;
|
tracing::error!(error = ?err, "failed to render WebDAV response");
|
||||||
|
AppError::internal("failed to render WebDAV response")
|
||||||
|
})?;
|
||||||
|
|
||||||
let response = Response::builder()
|
let response = Response::builder()
|
||||||
.status(multi_status())
|
.status(multi_status())
|
||||||
@@ -320,7 +322,7 @@ async fn stream_document(
|
|||||||
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
|
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::internal(format!("failed to presign document download: {err}")))?;
|
.storage_context("failed to presign document download")?;
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let mut request = client.request(method.clone(), url.clone());
|
let mut request = client.request(method.clone(), url.clone());
|
||||||
@@ -329,18 +331,17 @@ async fn stream_document(
|
|||||||
request = request.header(header::RANGE, range.clone());
|
request = request.header(header::RANGE, range.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
let upstream = request
|
let upstream = request.send().await.map_err(|err| {
|
||||||
.send()
|
tracing::error!(error = ?err, "failed to fetch document stream");
|
||||||
.await
|
AppError::internal("failed to fetch document stream")
|
||||||
.map_err(|err| AppError::internal(format!("failed to fetch document stream: {err}")))?;
|
})?;
|
||||||
|
|
||||||
let status =
|
let status =
|
||||||
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||||
|
|
||||||
if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) {
|
if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) {
|
||||||
return Err(AppError::internal(format!(
|
tracing::error!(status = %status, "upstream download returned error status");
|
||||||
"upstream download returned status {status}"
|
return Err(AppError::internal("failed to fetch document stream"));
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut builder = Response::builder().status(status);
|
let mut builder = Response::builder().status(status);
|
||||||
@@ -368,9 +369,10 @@ async fn stream_document(
|
|||||||
builder = builder.header(header::ETAG, format!("\"{}\"", version.id));
|
builder = builder.header(header::ETAG, format!("\"{}\"", version.id));
|
||||||
|
|
||||||
if method == Method::HEAD {
|
if method == Method::HEAD {
|
||||||
return builder
|
return builder.body(Body::empty()).map_err(|err| {
|
||||||
.body(Body::empty())
|
tracing::error!(error = ?err, "failed to build WebDAV response");
|
||||||
.map_err(|err| AppError::internal(format!("failed to build response: {err}")));
|
AppError::internal("failed to build WebDAV response")
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let stream = upstream
|
let stream = upstream
|
||||||
@@ -378,9 +380,10 @@ async fn stream_document(
|
|||||||
.map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)));
|
.map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)));
|
||||||
let body = Body::from_stream(stream);
|
let body = Body::from_stream(stream);
|
||||||
|
|
||||||
builder
|
builder.body(body).map_err(|err| {
|
||||||
.body(body)
|
tracing::error!(error = ?err, "failed to build WebDAV response");
|
||||||
.map_err(|err| AppError::internal(format!("failed to build response: {err}")))
|
AppError::internal("failed to build WebDAV response")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavContext>, AppError> {
|
fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavContext>, AppError> {
|
||||||
@@ -454,11 +457,11 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
|
|||||||
.inner_join(tenant_dsl::tenants)
|
.inner_join(tenant_dsl::tenants)
|
||||||
.filter(memberships_dsl::user_id.eq(user.id))
|
.filter(memberships_dsl::user_id.eq(user.id))
|
||||||
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
|
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
|
||||||
.select((tenant_dsl::id, tenant_dsl::slug))
|
.select((tenant_dsl::id, tenant_dsl::name))
|
||||||
.first::<(Uuid, String)>(&mut conn)
|
.first::<(Uuid, String)>(&mut conn)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
|
|
||||||
let (tenant_id, _slug) = match tenant_row {
|
let (tenant_id, _name) = match tenant_row {
|
||||||
Some(row) => row,
|
Some(row) => row,
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
|
|||||||
+48
-5
@@ -1,5 +1,11 @@
|
|||||||
// @generated automatically by Diesel CLI.
|
// @generated automatically by Diesel CLI.
|
||||||
|
|
||||||
|
pub mod sql_types {
|
||||||
|
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
||||||
|
#[diesel(postgres_type(name = "tenant_status"))]
|
||||||
|
pub struct TenantStatus;
|
||||||
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
correspondents (id) {
|
correspondents (id) {
|
||||||
id -> Uuid,
|
id -> Uuid,
|
||||||
@@ -148,15 +154,19 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
|
use diesel::sql_types::*;
|
||||||
|
use super::sql_types::TenantStatus;
|
||||||
|
|
||||||
tenants (id) {
|
tenants (id) {
|
||||||
id -> Uuid,
|
id -> Uuid,
|
||||||
slug -> Text,
|
name -> Text,
|
||||||
storage_root -> Nullable<Text>,
|
storage_root -> Nullable<Text>,
|
||||||
quickwit_index -> Nullable<Text>,
|
quickwit_index -> Nullable<Text>,
|
||||||
status -> Text,
|
|
||||||
config -> Jsonb,
|
config -> Jsonb,
|
||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
updated_at -> Timestamptz,
|
updated_at -> Timestamptz,
|
||||||
|
status -> TenantStatus,
|
||||||
|
created_by -> Nullable<Uuid>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,24 +175,53 @@ diesel::table! {
|
|||||||
id -> Uuid,
|
id -> Uuid,
|
||||||
user_id -> Uuid,
|
user_id -> Uuid,
|
||||||
tenant_id -> Uuid,
|
tenant_id -> Uuid,
|
||||||
role -> Text,
|
|
||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
updated_at -> Timestamptz,
|
updated_at -> Timestamptz,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
user_passkeys (id) {
|
||||||
|
id -> Uuid,
|
||||||
|
user_id -> Uuid,
|
||||||
|
credential_id -> Bytea,
|
||||||
|
public_key -> Bytea,
|
||||||
|
credential -> Jsonb,
|
||||||
|
sign_count -> Int8,
|
||||||
|
transports -> Array<Nullable<Text>>,
|
||||||
|
aaguid -> Nullable<Uuid>,
|
||||||
|
nickname -> Nullable<Text>,
|
||||||
|
created_at -> Timestamptz,
|
||||||
|
updated_at -> Timestamptz,
|
||||||
|
last_used_at -> Nullable<Timestamptz>,
|
||||||
|
revoked_at -> Nullable<Timestamptz>,
|
||||||
|
revoked_by -> Nullable<Uuid>,
|
||||||
|
revoked_reason -> Nullable<Text>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
users (id) {
|
users (id) {
|
||||||
id -> Uuid,
|
id -> Uuid,
|
||||||
#[max_length = 100]
|
#[max_length = 100]
|
||||||
username -> Varchar,
|
username -> Varchar,
|
||||||
#[max_length = 255]
|
|
||||||
password_hash -> Varchar,
|
|
||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
updated_at -> Timestamptz,
|
updated_at -> Timestamptz,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
webauthn_challenges (id) {
|
||||||
|
id -> Uuid,
|
||||||
|
user_id -> Nullable<Uuid>,
|
||||||
|
purpose -> Text,
|
||||||
|
challenge -> Bytea,
|
||||||
|
state -> Bytea,
|
||||||
|
created_at -> Timestamptz,
|
||||||
|
expires_at -> Timestamptz,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
webdav_tokens (id) {
|
webdav_tokens (id) {
|
||||||
id -> Uuid,
|
id -> Uuid,
|
||||||
@@ -221,6 +260,8 @@ diesel::joinable!(refresh_tokens -> users (user_id));
|
|||||||
diesel::joinable!(tags -> tenants (tenant_id));
|
diesel::joinable!(tags -> tenants (tenant_id));
|
||||||
diesel::joinable!(user_memberships -> tenants (tenant_id));
|
diesel::joinable!(user_memberships -> tenants (tenant_id));
|
||||||
diesel::joinable!(user_memberships -> users (user_id));
|
diesel::joinable!(user_memberships -> users (user_id));
|
||||||
|
diesel::joinable!(user_passkeys -> users (user_id));
|
||||||
|
diesel::joinable!(webauthn_challenges -> users (user_id));
|
||||||
diesel::joinable!(webdav_tokens -> tenants (tenant_id));
|
diesel::joinable!(webdav_tokens -> tenants (tenant_id));
|
||||||
diesel::joinable!(webdav_tokens -> users (user_id));
|
diesel::joinable!(webdav_tokens -> users (user_id));
|
||||||
|
|
||||||
@@ -238,6 +279,8 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||||||
tags,
|
tags,
|
||||||
tenants,
|
tenants,
|
||||||
user_memberships,
|
user_memberships,
|
||||||
|
user_passkeys,
|
||||||
users,
|
users,
|
||||||
|
webauthn_challenges,
|
||||||
webdav_tokens,
|
webdav_tokens,
|
||||||
);
|
);
|
||||||
|
|||||||
+19
-6
@@ -7,7 +7,7 @@ use diesel::{
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::jwt::JwtService,
|
auth::{jwt::JwtService, passkeys::PasskeyService},
|
||||||
config::AppConfig,
|
config::AppConfig,
|
||||||
db::PgPool,
|
db::PgPool,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
@@ -24,6 +24,7 @@ pub struct AppState {
|
|||||||
storage: Arc<dyn ObjectStorage>,
|
storage: Arc<dyn ObjectStorage>,
|
||||||
pub jwt: JwtService,
|
pub jwt: JwtService,
|
||||||
pub tenants: TenantService,
|
pub tenants: TenantService,
|
||||||
|
pub passkeys: Option<PasskeyService>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -52,12 +53,21 @@ impl AppState {
|
|||||||
let config = Arc::new(config);
|
let config = Arc::new(config);
|
||||||
let tenants = TenantService::new(pool.clone());
|
let tenants = TenantService::new(pool.clone());
|
||||||
|
|
||||||
|
let passkeys = match PasskeyService::try_new(&config) {
|
||||||
|
Ok(service) => service,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(error = ?err, "passkey service disabled due to configuration");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
pool,
|
pool,
|
||||||
config,
|
config,
|
||||||
storage,
|
storage,
|
||||||
jwt,
|
jwt,
|
||||||
tenants,
|
tenants,
|
||||||
|
passkeys,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,14 +79,17 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn db_unscoped(&self) -> AppResult<PgPooledConnection> {
|
pub(crate) fn db_unscoped(&self) -> AppResult<PgPooledConnection> {
|
||||||
self.pool
|
self.pool.get().map_err(|err| {
|
||||||
.get()
|
tracing::error!(error = ?err, "database pool error");
|
||||||
.map_err(|err| AppError::internal(format!("database pool error: {err}")))
|
AppError::internal("database pool error")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn storage_for_tenant(&self, tenant_id: Uuid) -> AppResult<TenantStorage> {
|
pub fn storage_for_tenant(&self, tenant_id: Uuid) -> AppResult<TenantStorage> {
|
||||||
let tenant = self.tenants.get_by_id(tenant_id)?;
|
let tenant = self.tenants.get_by_id(tenant_id)?;
|
||||||
TenantStorage::new(self.storage.clone(), &tenant)
|
TenantStorage::new(self.storage.clone(), &tenant).map_err(|err| {
|
||||||
.map_err(|err| AppError::internal(format!("tenant storage error: {err}")))
|
tracing::error!(error = ?err, "tenant storage error");
|
||||||
|
AppError::internal("tenant storage error")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+93
-28
@@ -1,13 +1,13 @@
|
|||||||
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
|
||||||
use diesel::{pg::PgConnection, prelude::*, sql_types::Text};
|
use diesel::{pg::PgConnection, prelude::*, sql_types::Text};
|
||||||
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
db::PgPool,
|
db::PgPool,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::Tenant,
|
jobs::{enqueue_job, JOB_PROVISION_TENANT},
|
||||||
|
models::{Tenant, TenantStatus},
|
||||||
schema::tenants::dsl,
|
schema::tenants::dsl,
|
||||||
state::AppState,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct TenantRepository;
|
pub struct TenantRepository;
|
||||||
@@ -17,9 +17,9 @@ impl TenantRepository {
|
|||||||
dsl::tenants.find(tenant_id).first(conn).map_err(Into::into)
|
dsl::tenants.find(tenant_id).first(conn).map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_by_slug(conn: &mut PgConnection, slug: &str) -> AppResult<Tenant> {
|
pub fn get_by_name(conn: &mut PgConnection, name: &str) -> AppResult<Tenant> {
|
||||||
dsl::tenants
|
dsl::tenants
|
||||||
.filter(dsl::slug.eq(slug))
|
.filter(dsl::name.eq(name))
|
||||||
.first(conn)
|
.first(conn)
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
@@ -40,27 +40,91 @@ impl TenantService {
|
|||||||
Ok(tenant)
|
Ok(tenant)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_by_slug(&self, slug: &str) -> AppResult<Tenant> {
|
pub fn get_by_name(&self, name: &str) -> AppResult<Tenant> {
|
||||||
let slug_owned = slug.to_owned();
|
let name_owned = name.to_owned();
|
||||||
let tenant = self.load(|conn| TenantRepository::get_by_slug(conn, &slug_owned))?;
|
let tenant = self.load(|conn| TenantRepository::get_by_name(conn, &name_owned))?;
|
||||||
Ok(tenant)
|
Ok(tenant)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tenant_id_for_slug(&self, slug: &str) -> AppResult<Uuid> {
|
pub fn create_tenant(
|
||||||
Ok(self.get_by_slug(slug)?.id)
|
&self,
|
||||||
|
name: &str,
|
||||||
|
storage_root: Option<&str>,
|
||||||
|
quickwit_index: Option<&str>,
|
||||||
|
status: TenantStatus,
|
||||||
|
initial_members: &[Uuid],
|
||||||
|
created_by: Option<Uuid>,
|
||||||
|
) -> AppResult<Tenant> {
|
||||||
|
let mut conn = self.pool.get().map_err(|err| {
|
||||||
|
tracing::error!(error = ?err, "database pool error");
|
||||||
|
AppError::internal("database pool error")
|
||||||
|
})?;
|
||||||
|
self.create_tenant_with_conn(
|
||||||
|
&mut conn,
|
||||||
|
name,
|
||||||
|
storage_root,
|
||||||
|
quickwit_index,
|
||||||
|
status,
|
||||||
|
initial_members,
|
||||||
|
created_by,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load<F>(&self, loader: F) -> AppResult<Tenant>
|
fn load<F>(&self, loader: F) -> AppResult<Tenant>
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut PgConnection) -> AppResult<Tenant>,
|
F: FnOnce(&mut PgConnection) -> AppResult<Tenant>,
|
||||||
{
|
{
|
||||||
let mut conn = self
|
let mut conn = self.pool.get().map_err(|err| {
|
||||||
.pool
|
tracing::error!(error = ?err, "database pool error");
|
||||||
.get()
|
AppError::internal("database pool error")
|
||||||
.map_err(|err| AppError::internal(format!("database pool error: {err}")))?;
|
})?;
|
||||||
let tenant = loader(&mut conn)?;
|
let tenant = loader(&mut conn)?;
|
||||||
Ok(tenant)
|
Ok(tenant)
|
||||||
}
|
}
|
||||||
|
pub fn create_tenant_with_conn(
|
||||||
|
&self,
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
name: &str,
|
||||||
|
storage_root: Option<&str>,
|
||||||
|
quickwit_index: Option<&str>,
|
||||||
|
status: TenantStatus,
|
||||||
|
initial_members: &[Uuid],
|
||||||
|
created_by: Option<Uuid>,
|
||||||
|
) -> AppResult<Tenant> {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(AppError::bad_request("tenant name must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let storage_root = normalize_storage_root(storage_root, id);
|
||||||
|
let quickwit_index = normalize_quickwit_index(quickwit_index, id);
|
||||||
|
|
||||||
|
diesel::insert_into(dsl::tenants)
|
||||||
|
.values((
|
||||||
|
dsl::id.eq(id),
|
||||||
|
dsl::name.eq(name),
|
||||||
|
dsl::storage_root.eq(Some(storage_root.clone())),
|
||||||
|
dsl::quickwit_index.eq(Some(quickwit_index.clone())),
|
||||||
|
dsl::config.eq(json!({})),
|
||||||
|
dsl::status.eq(status),
|
||||||
|
dsl::created_by.eq(created_by),
|
||||||
|
))
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
if status == TenantStatus::Creating {
|
||||||
|
let payload = json!({
|
||||||
|
"members": initial_members,
|
||||||
|
});
|
||||||
|
|
||||||
|
enqueue_job(conn, id, JOB_PROVISION_TENANT, payload, None).map_err(|err| {
|
||||||
|
tracing::error!(error = ?err, tenant_id = %id, "failed to enqueue tenant provisioning job");
|
||||||
|
AppError::internal("failed to enqueue tenant provisioning job")
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
TenantRepository::get_by_id(conn, id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<()> {
|
pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<()> {
|
||||||
@@ -71,21 +135,22 @@ pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<(
|
|||||||
.map_err(AppError::from)
|
.map_err(AppError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct TenantContext {
|
fn normalize_storage_root(raw: Option<&str>, tenant_id: Uuid) -> String {
|
||||||
pub tenant: Tenant,
|
match raw.map(str::trim) {
|
||||||
|
Some(root) if !root.is_empty() => {
|
||||||
|
let mut owned = root.to_owned();
|
||||||
|
if !owned.ends_with('/') {
|
||||||
|
owned.push('/');
|
||||||
|
}
|
||||||
|
owned
|
||||||
|
}
|
||||||
|
_ => format!("tenants/{tenant_id}/"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
fn normalize_quickwit_index(raw: Option<&str>, tenant_id: Uuid) -> String {
|
||||||
impl FromRequestParts<AppState> for TenantContext {
|
match raw.map(str::trim) {
|
||||||
type Rejection = AppError;
|
Some(value) if !value.is_empty() => value.to_owned(),
|
||||||
|
_ => format!("documents-{tenant_id}"),
|
||||||
async fn from_request_parts(
|
|
||||||
_parts: &mut Parts,
|
|
||||||
state: &AppState,
|
|
||||||
) -> Result<Self, Self::Rejection> {
|
|
||||||
let tenant = state
|
|
||||||
.tenants
|
|
||||||
.get_by_slug(&state.config.default_tenant_slug)?;
|
|
||||||
Ok(Self { tenant })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use diesel::result::Error as DieselError;
|
||||||
|
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
|
||||||
|
pub trait DbResultExt<T> {
|
||||||
|
fn db_context(self, context: &'static str) -> AppResult<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> DbResultExt<T> for Result<T, DieselError> {
|
||||||
|
fn db_context(self, context: &'static str) -> AppResult<T> {
|
||||||
|
self.map_err(|err| match err {
|
||||||
|
DieselError::NotFound => AppError::not_found(),
|
||||||
|
other => {
|
||||||
|
tracing::error!(error = ?other, "{context}");
|
||||||
|
AppError::internal(context)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait StorageResultExt<T> {
|
||||||
|
fn storage_context(self, context: &'static str) -> AppResult<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> StorageResultExt<T> for Result<T, anyhow::Error> {
|
||||||
|
fn storage_context(self, context: &'static str) -> AppResult<T> {
|
||||||
|
self.map_err(|err| {
|
||||||
|
tracing::error!(error = ?err, "{context}");
|
||||||
|
AppError::internal(context)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod bootstrap;
|
pub mod bootstrap;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
|
pub mod error;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod json;
|
pub mod json;
|
||||||
pub mod storage_paths;
|
pub mod storage_paths;
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ use async_trait::async_trait;
|
|||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
|
||||||
use tokio::task;
|
use tokio::task;
|
||||||
use tracing::{error, warn};
|
use tracing::{error, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
documents::search::{build_quickwit_ingest_record, quickwit_ingest},
|
||||||
jobs::JOB_INDEX_DOCUMENT_TEXT,
|
jobs::JOB_INDEX_DOCUMENT_TEXT,
|
||||||
models::{Document, DocumentVersion},
|
models::{Document, DocumentVersion},
|
||||||
schema::{document_asset_objects, document_assets, document_versions, documents},
|
schema::{document_asset_objects, document_assets, document_versions, documents},
|
||||||
@@ -18,7 +18,9 @@ use crate::{
|
|||||||
storage::TenantStorage,
|
storage::TenantStorage,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{ocr::OCR_TEXT_ASSET_TYPE, JobExecution, JobHandler};
|
use super::{
|
||||||
|
fetch_version_object, handle_fetch_error, ocr::OCR_TEXT_ASSET_TYPE, JobExecution, JobHandler,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct IndexPayload {
|
struct IndexPayload {
|
||||||
@@ -114,21 +116,23 @@ impl JobHandler for IndexDocumentTextJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let s3_key = context.text_s3_key.unwrap();
|
let s3_key = context.text_s3_key.unwrap();
|
||||||
let text = match storage.get_object(&s3_key).await {
|
let bytes = match fetch_version_object(
|
||||||
Ok(bytes) => match String::from_utf8(bytes) {
|
&context.version,
|
||||||
Ok(text) => text,
|
&storage,
|
||||||
Err(err) => {
|
&s3_key,
|
||||||
warn!(job_id = %job.id, error = %err, "ocr text not valid UTF-8");
|
state.config.worker_max_document_bytes,
|
||||||
return JobExecution::Failed {
|
)
|
||||||
error: "ocr text not valid UTF-8".into(),
|
.await
|
||||||
};
|
{
|
||||||
}
|
Ok(bytes) => bytes,
|
||||||
},
|
Err(err) => return handle_fetch_error(&job, err, "failed to download ocr text"),
|
||||||
|
};
|
||||||
|
let text = match String::from_utf8(bytes) {
|
||||||
|
Ok(text) => text,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(job_id = %job.id, error = %err, "failed to download ocr text");
|
warn!(job_id = %job.id, error = %err, "ocr text not valid UTF-8");
|
||||||
return JobExecution::Retry {
|
return JobExecution::Failed {
|
||||||
delay: Duration::from_secs(30),
|
error: "ocr text not valid UTF-8".into(),
|
||||||
error: err.to_string(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -140,43 +144,13 @@ impl JobHandler for IndexDocumentTextJob {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = client;
|
let record =
|
||||||
let url = format!(
|
build_quickwit_ingest_record(&context.document, &context.version, job.tenant_id, &text);
|
||||||
"{}/api/v1/{}/ingest?commit=auto",
|
|
||||||
quickwit_endpoint, quickwit_index
|
|
||||||
);
|
|
||||||
let payload = json!({
|
|
||||||
"document_id": context.document.id,
|
|
||||||
"version_id": context.version.id,
|
|
||||||
"tenant_id": job.tenant_id,
|
|
||||||
"title": context.document.title.to_lowercase(),
|
|
||||||
"text": text.to_lowercase()
|
|
||||||
});
|
|
||||||
|
|
||||||
let body = serde_json::to_string(&payload).unwrap();
|
match quickwit_ingest(&client, &quickwit_endpoint, &quickwit_index, &[record]).await {
|
||||||
|
Ok(()) => JobExecution::Success,
|
||||||
match client
|
|
||||||
.post(&url)
|
|
||||||
.header("content-type", "application/x-ndjson")
|
|
||||||
.body(format!("{}\n", body))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(response) => {
|
|
||||||
if response.status().is_success() {
|
|
||||||
JobExecution::Success
|
|
||||||
} else {
|
|
||||||
let status = response.status();
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
warn!(job_id = %job.id, %status, %body, "quickwit ingest failed");
|
|
||||||
JobExecution::Retry {
|
|
||||||
delay: Duration::from_secs(30),
|
|
||||||
error: format!("quickwit ingest failed with status {status}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(job_id = %job.id, error = %err, "quickwit request failed");
|
warn!(job_id = %job.id, error = %err, "quickwit ingest failed");
|
||||||
JobExecution::Retry {
|
JobExecution::Retry {
|
||||||
delay: Duration::from_secs(30),
|
delay: Duration::from_secs(30),
|
||||||
error: err.to_string(),
|
error: err.to_string(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use anyhow::Error as AnyhowError;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
@@ -14,8 +15,11 @@ use crate::{
|
|||||||
pub mod analyze;
|
pub mod analyze;
|
||||||
pub mod index;
|
pub mod index;
|
||||||
pub mod ocr;
|
pub mod ocr;
|
||||||
|
pub mod tenants;
|
||||||
pub mod thumbnails;
|
pub mod thumbnails;
|
||||||
|
|
||||||
|
use tenants::ProvisionTenantJob;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum JobExecution {
|
pub enum JobExecution {
|
||||||
Success,
|
Success,
|
||||||
@@ -145,5 +149,70 @@ pub fn default_handlers() -> Vec<Arc<dyn JobHandler>> {
|
|||||||
Arc::new(thumbnails::GenerateThumbnailsJob::new()),
|
Arc::new(thumbnails::GenerateThumbnailsJob::new()),
|
||||||
Arc::new(ocr::GenerateOcrTextJob::new()),
|
Arc::new(ocr::GenerateOcrTextJob::new()),
|
||||||
Arc::new(index::IndexDocumentTextJob::new()),
|
Arc::new(index::IndexDocumentTextJob::new()),
|
||||||
|
Arc::new(ProvisionTenantJob::new()),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn check_worker_document_limit(
|
||||||
|
size_bytes: i64,
|
||||||
|
limit_bytes: u64,
|
||||||
|
) -> Result<(), (u64, u64)> {
|
||||||
|
let size = size_bytes.max(0) as u64;
|
||||||
|
if size > limit_bytes {
|
||||||
|
Err((size, limit_bytes))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) enum FetchVersionError {
|
||||||
|
TooLarge { size: u64, limit: u64 },
|
||||||
|
Storage(AnyhowError),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn fetch_version_object(
|
||||||
|
version: &crate::models::DocumentVersion,
|
||||||
|
storage: &TenantStorage,
|
||||||
|
s3_key: &str,
|
||||||
|
limit_bytes: u64,
|
||||||
|
) -> Result<Vec<u8>, FetchVersionError> {
|
||||||
|
check_worker_document_limit(version.size_bytes, limit_bytes)
|
||||||
|
.map_err(|(size, limit)| FetchVersionError::TooLarge { size, limit })?;
|
||||||
|
|
||||||
|
storage
|
||||||
|
.get_object(s3_key)
|
||||||
|
.await
|
||||||
|
.map_err(FetchVersionError::Storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn handle_fetch_error(
|
||||||
|
job: &crate::models::Job,
|
||||||
|
err: FetchVersionError,
|
||||||
|
message: &str,
|
||||||
|
) -> JobExecution {
|
||||||
|
match err {
|
||||||
|
FetchVersionError::TooLarge { size, limit } => {
|
||||||
|
warn!(
|
||||||
|
job_id = %job.id,
|
||||||
|
size_bytes = size,
|
||||||
|
limit_bytes = limit,
|
||||||
|
"document exceeds worker size limit"
|
||||||
|
);
|
||||||
|
JobExecution::Failed {
|
||||||
|
error: format!("document size {size} bytes exceeds worker limit of {limit} bytes"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FetchVersionError::Storage(err) => {
|
||||||
|
warn!(
|
||||||
|
job_id = %job.id,
|
||||||
|
error = %err,
|
||||||
|
context = message,
|
||||||
|
"failed to fetch object for worker"
|
||||||
|
);
|
||||||
|
JobExecution::Retry {
|
||||||
|
delay: Duration::from_secs(30),
|
||||||
|
error: err.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+11
-10
@@ -30,7 +30,7 @@ use crate::{
|
|||||||
utils::storage_paths::document_asset_object_prefix,
|
utils::storage_paths::document_asset_object_prefix,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{JobExecution, JobHandler};
|
use super::{fetch_version_object, handle_fetch_error, JobExecution, JobHandler};
|
||||||
|
|
||||||
pub const OCR_TEXT_ASSET_TYPE: &str = "ocr-text";
|
pub const OCR_TEXT_ASSET_TYPE: &str = "ocr-text";
|
||||||
const MIN_TEXT_LENGTH: usize = 50;
|
const MIN_TEXT_LENGTH: usize = 50;
|
||||||
@@ -99,15 +99,16 @@ impl JobHandler for GenerateOcrTextJob {
|
|||||||
return JobExecution::Success;
|
return JobExecution::Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
let bytes = match storage.get_object(&context.version.s3_key).await {
|
let bytes = match fetch_version_object(
|
||||||
|
&context.version,
|
||||||
|
&storage,
|
||||||
|
&context.version.s3_key,
|
||||||
|
state.config.worker_max_document_bytes,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(bytes) => bytes,
|
Ok(bytes) => bytes,
|
||||||
Err(err) => {
|
Err(err) => return handle_fetch_error(&job, err, "failed to fetch document for ocr"),
|
||||||
warn!(job_id = %job.id, error = %err, "failed to fetch document for ocr");
|
|
||||||
return JobExecution::Retry {
|
|
||||||
delay: Duration::from_secs(30),
|
|
||||||
error: err.to_string(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let doc_meta = PdfDocumentMeta {
|
let doc_meta = PdfDocumentMeta {
|
||||||
@@ -172,7 +173,7 @@ impl JobHandler for GenerateOcrTextJob {
|
|||||||
.put_object(
|
.put_object(
|
||||||
&s3_key,
|
&s3_key,
|
||||||
generation.text.into_bytes(),
|
generation.text.into_bytes(),
|
||||||
Some("text/plain".into()),
|
Some("text/plain; charset=utf-8".into()),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::Utc;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tracing::warn;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::documents::search::ensure_quickwit_index;
|
||||||
|
use crate::jobs::JOB_PROVISION_TENANT;
|
||||||
|
use crate::models::{NewUserMembership, TenantStatus};
|
||||||
|
use crate::schema::{tenants, user_memberships};
|
||||||
|
use crate::state::AppState;
|
||||||
|
use crate::tenants::TenantRepository;
|
||||||
|
use crate::workers::{JobExecution, JobHandler};
|
||||||
|
|
||||||
|
pub struct ProvisionTenantJob;
|
||||||
|
|
||||||
|
impl ProvisionTenantJob {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl JobHandler for ProvisionTenantJob {
|
||||||
|
fn job_type(&self) -> &'static str {
|
||||||
|
JOB_PROVISION_TENANT
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(
|
||||||
|
&self,
|
||||||
|
state: Arc<AppState>,
|
||||||
|
job: crate::models::Job,
|
||||||
|
_storage: crate::storage::TenantStorage,
|
||||||
|
) -> JobExecution {
|
||||||
|
let mut conn = match state.db_unscoped() {
|
||||||
|
Ok(conn) => conn,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(job_id = %job.id, error = ?err, "failed to get connection for tenant provisioning");
|
||||||
|
return JobExecution::Retry {
|
||||||
|
delay: std::time::Duration::from_secs(30),
|
||||||
|
error: "database connection unavailable".into(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let tenant = match TenantRepository::get_by_id(&mut conn, job.tenant_id) {
|
||||||
|
Ok(tenant) => tenant,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(job_id = %job.id, error = ?err, "tenant not found for provisioning");
|
||||||
|
return JobExecution::Failed {
|
||||||
|
error: "tenant not found".into(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if tenant.status == TenantStatus::Active {
|
||||||
|
warn!(
|
||||||
|
job_id = %job.id,
|
||||||
|
tenant_id = %tenant.id,
|
||||||
|
"tenant already active; skipping provisioning"
|
||||||
|
);
|
||||||
|
return JobExecution::Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
if tenant.status != TenantStatus::Creating {
|
||||||
|
warn!(
|
||||||
|
job_id = %job.id,
|
||||||
|
tenant_id = %tenant.id,
|
||||||
|
status = %tenant.status.as_str(),
|
||||||
|
"tenant not in creating state; skipping provisioning"
|
||||||
|
);
|
||||||
|
return JobExecution::Failed {
|
||||||
|
error: format!(
|
||||||
|
"tenant status '{}' not eligible for provisioning",
|
||||||
|
tenant.status.as_str()
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let endpoint = match &state.config.quickwit_endpoint {
|
||||||
|
Some(endpoint) => endpoint.trim_end_matches('/').to_owned(),
|
||||||
|
None => {
|
||||||
|
warn!(
|
||||||
|
job_id = %job.id,
|
||||||
|
tenant_id = %tenant.id,
|
||||||
|
"quickwit endpoint not configured; retrying"
|
||||||
|
);
|
||||||
|
return JobExecution::Retry {
|
||||||
|
delay: std::time::Duration::from_secs(30),
|
||||||
|
error: "quickwit endpoint not configured".into(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let index_id = tenant
|
||||||
|
.quickwit_index
|
||||||
|
.as_deref()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.unwrap_or_else(|| format!("documents-{}", tenant.id));
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
if let Err(err) = ensure_quickwit_index(&client, &endpoint, &index_id).await {
|
||||||
|
warn!(
|
||||||
|
job_id = %job.id,
|
||||||
|
tenant_id = %tenant.id,
|
||||||
|
error = %err,
|
||||||
|
"failed to ensure quickwit index"
|
||||||
|
);
|
||||||
|
return JobExecution::Retry {
|
||||||
|
delay: std::time::Duration::from_secs(30),
|
||||||
|
error: err.to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(members) = ProvisionPayload::from_job(&job) {
|
||||||
|
for member in members {
|
||||||
|
let new_membership = NewUserMembership {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
user_id: member,
|
||||||
|
tenant_id: tenant.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = diesel::insert_into(user_memberships::table)
|
||||||
|
.values(&new_membership)
|
||||||
|
.on_conflict((user_memberships::user_id, user_memberships::tenant_id))
|
||||||
|
.do_nothing()
|
||||||
|
.execute(&mut conn)
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
job_id = %job.id,
|
||||||
|
tenant_id = %tenant.id,
|
||||||
|
user_id = %member,
|
||||||
|
error = %err,
|
||||||
|
"failed to assign initial membership"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = diesel::update(tenants::table.find(tenant.id))
|
||||||
|
.set((
|
||||||
|
tenants::status.eq(TenantStatus::Active),
|
||||||
|
tenants::quickwit_index.eq(Some(index_id)),
|
||||||
|
tenants::updated_at.eq(Utc::now().naive_utc()),
|
||||||
|
))
|
||||||
|
.execute(&mut conn)
|
||||||
|
{
|
||||||
|
warn!(job_id = %job.id, error = %err, "failed to activate tenant");
|
||||||
|
return JobExecution::Retry {
|
||||||
|
delay: std::time::Duration::from_secs(30),
|
||||||
|
error: format!("failed to update tenant status: {err}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
JobExecution::Success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
struct ProvisionPayload {
|
||||||
|
#[serde(default)]
|
||||||
|
members: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProvisionPayload {
|
||||||
|
fn from_job(job: &crate::models::Job) -> Option<Vec<Uuid>> {
|
||||||
|
serde_json::from_value(job.payload.clone())
|
||||||
|
.map(|payload: ProvisionPayload| payload.members)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,10 @@ use crate::{
|
|||||||
utils::storage_paths::document_asset_object_key,
|
utils::storage_paths::document_asset_object_key,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{analyze::determine_thumbnail_support, JobExecution, JobHandler};
|
use super::{
|
||||||
|
analyze::determine_thumbnail_support, fetch_version_object, handle_fetch_error, JobExecution,
|
||||||
|
JobHandler,
|
||||||
|
};
|
||||||
|
|
||||||
const THUMBNAIL_WIDTH: u32 = 512;
|
const THUMBNAIL_WIDTH: u32 = 512;
|
||||||
const THUMBNAIL_HEIGHT: u32 = 512;
|
const THUMBNAIL_HEIGHT: u32 = 512;
|
||||||
@@ -96,15 +99,16 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
return JobExecution::Success;
|
return JobExecution::Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
let bytes = match storage.get_object(&initial.version.s3_key).await {
|
let bytes = match fetch_version_object(
|
||||||
|
&initial.version,
|
||||||
|
&storage,
|
||||||
|
&initial.version.s3_key,
|
||||||
|
state.config.worker_max_document_bytes,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(bytes) => bytes,
|
Ok(bytes) => bytes,
|
||||||
Err(err) => {
|
Err(err) => return handle_fetch_error(&job, err, "thumbnail fetch failed; will retry"),
|
||||||
warn!(job_id = %job.id, error = %err, "thumbnail fetch failed; will retry");
|
|
||||||
return JobExecution::Retry {
|
|
||||||
delay: Duration::from_secs(30),
|
|
||||||
error: err.to_string(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let generation = match generate_preview_and_thumbnail(&initial.document, &bytes) {
|
let generation = match generate_preview_and_thumbnail(&initial.document, &bytes) {
|
||||||
|
|||||||
+414
-57
@@ -1,15 +1,27 @@
|
|||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use axum::body::Body;
|
|
||||||
use axum::http::{header::SET_COOKIE, StatusCode};
|
use axum::http::{header::SET_COOKIE, StatusCode};
|
||||||
use backend::models::NewUserMembership;
|
use backend::auth::passkeys::{
|
||||||
use backend::schema::{tenants, user_memberships};
|
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
|
||||||
|
RegistrationChallengeResponse,
|
||||||
|
};
|
||||||
|
use backend::models::{NewRefreshToken, NewUserMembership, TenantStatus, UserPasskey};
|
||||||
|
use backend::openapi::schemas::PasskeySummary;
|
||||||
|
use backend::schema::{refresh_tokens, tenants, user_memberships, users};
|
||||||
|
use chrono::{Duration as ChronoDuration, Utc};
|
||||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use webauthn_rs_core::proto::{
|
||||||
|
AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential,
|
||||||
|
RegisterPublicKeyCredential,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct AuthenticatedUser {
|
struct AuthenticatedUser {
|
||||||
@@ -23,7 +35,8 @@ struct ErrorResponse {
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct LoginTenant {
|
struct LoginTenant {
|
||||||
slug: String,
|
id: Uuid,
|
||||||
|
name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -32,16 +45,27 @@ struct LoginResponse {
|
|||||||
tenant: LoginTenant,
|
tenant: LoginTenant,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SignupStartResponse {
|
||||||
|
signup_token: String,
|
||||||
|
challenge: RegistrationChallengeResponse,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct TenantSelectionResponse {
|
struct TenantSelectionResponse {
|
||||||
access_token: String,
|
access_token: String,
|
||||||
tenants: Vec<TenantSummary>,
|
tenants: Vec<TenantSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TenantListResponse {
|
||||||
|
tenants: Vec<TenantSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct TenantSummary {
|
struct TenantSummary {
|
||||||
tenant_id: Uuid,
|
id: Uuid,
|
||||||
slug: String,
|
name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -72,15 +96,307 @@ async fn login_rejects_unknown_user() -> Result<()> {
|
|||||||
|
|
||||||
let payload = json!({ "username": "ghost", "password": "nope" });
|
let payload = json!({ "username": "ghost", "password": "nope" });
|
||||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
let body = body_to_vec(response.into_body()).await?;
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
let err: ErrorResponse = serde_json::from_slice(&body)?;
|
let err: ErrorResponse = serde_json::from_slice(&body)?;
|
||||||
assert_eq!(err.error, "unauthorized");
|
assert_eq!(err.error, "password authentication is no longer supported");
|
||||||
|
|
||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn signup_start_and_finish_require_valid_passkey() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let payload = json!({ "username": "signup-user" });
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.post_json("/api/auth/signup/start", &payload, None)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
let start: SignupStartResponse = serde_json::from_slice(&body)?;
|
||||||
|
assert!(!start.signup_token.is_empty());
|
||||||
|
assert_ne!(start.challenge.challenge_id, Uuid::nil());
|
||||||
|
|
||||||
|
app.with_conn(|conn| {
|
||||||
|
let exists: bool = diesel::select(diesel::dsl::exists(
|
||||||
|
users::table.filter(users::username.eq("signup-user")),
|
||||||
|
))
|
||||||
|
.get_result(conn)?;
|
||||||
|
assert!(!exists);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let finish_payload = json!({
|
||||||
|
"signup_token": start.signup_token,
|
||||||
|
"credential": fake_register_credential(),
|
||||||
|
});
|
||||||
|
let finish_response = app
|
||||||
|
.post_json("/api/auth/signup/finish", &finish_payload, None)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(finish_response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn passkey_register_start_creates_challenge() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let password = "secret";
|
||||||
|
app.insert_user("passkey-user", password, "admin").await?;
|
||||||
|
|
||||||
|
let (login, _) = login_with_session(&app, "passkey-user", password).await?;
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.post_json(
|
||||||
|
"/api/auth/passkeys/register/start",
|
||||||
|
&json!({}),
|
||||||
|
Some(&login.access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
let challenge: RegistrationChallengeResponse = serde_json::from_slice(&body)?;
|
||||||
|
assert_ne!(challenge.challenge_id, Uuid::nil());
|
||||||
|
let challenge_id = challenge.challenge_id;
|
||||||
|
|
||||||
|
app.with_conn(move |conn| {
|
||||||
|
use backend::schema::webauthn_challenges::dsl;
|
||||||
|
use diesel::dsl::{exists, select};
|
||||||
|
|
||||||
|
let exists: bool = select(exists(
|
||||||
|
dsl::webauthn_challenges.filter(dsl::id.eq(challenge_id)),
|
||||||
|
))
|
||||||
|
.get_result(conn)?;
|
||||||
|
assert!(exists, "challenge not persisted");
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn passkey_register_finish_rejects_unknown_challenge() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let password = "secret";
|
||||||
|
app.insert_user("passkey-register", password, "admin")
|
||||||
|
.await?;
|
||||||
|
let (login, _) = login_with_session(&app, "passkey-register", password).await?;
|
||||||
|
|
||||||
|
let payload = PasskeyRegistrationFinishPayload {
|
||||||
|
challenge_id: Uuid::new_v4(),
|
||||||
|
credential: fake_register_credential(),
|
||||||
|
nickname: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.post_json(
|
||||||
|
"/api/auth/passkeys/register/finish",
|
||||||
|
&payload,
|
||||||
|
Some(&login.access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn passkey_login_start_requires_passkey() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let password = "secret";
|
||||||
|
app.insert_user("passkey-login", password, "admin").await?;
|
||||||
|
|
||||||
|
let payload = PasskeyLoginStartPayload {
|
||||||
|
username: "passkey-login".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.post_json("/api/auth/passkeys/login/start", &payload, None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn passkey_login_start_unknown_user_returns_not_found() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let payload = PasskeyLoginStartPayload {
|
||||||
|
username: "nobody".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.post_json("/api/auth/passkeys/login/start", &payload, None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn passkey_login_finish_rejects_invalid_challenge() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let payload = PasskeyLoginFinishPayload {
|
||||||
|
challenge_id: Uuid::new_v4(),
|
||||||
|
credential: fake_authentication_credential(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.post_json("/api/auth/passkeys/login/finish", &payload, None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_passkeys_returns_entries() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let password = "secret";
|
||||||
|
let user_id = app.insert_user("passkey-owner", password, "admin").await?;
|
||||||
|
app.insert_passkey(user_id, Some("Laptop")).await?;
|
||||||
|
|
||||||
|
let (session, _) = login_with_session(&app, "passkey-owner", password).await?;
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.get("/api/profile/passkeys", Some(&session.access_token))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
let summaries: Vec<PasskeySummary> = serde_json::from_slice(&body)?;
|
||||||
|
assert_eq!(summaries.len(), 1);
|
||||||
|
assert_eq!(summaries[0].nickname.as_deref(), Some("Laptop"));
|
||||||
|
assert!(summaries[0].revoked_at.is_none());
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_passkey_soft_revokes() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let password = "secret";
|
||||||
|
let user_id = app.insert_user("passkey-delete", password, "admin").await?;
|
||||||
|
let passkey_id = app.insert_passkey(user_id, Some("Phone")).await?;
|
||||||
|
app.insert_passkey(user_id, Some("Backup")).await?;
|
||||||
|
let (session, _) = login_with_session(&app, "passkey-delete", password).await?;
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.delete(
|
||||||
|
&format!("/api/profile/passkeys/{}?reason=lost", passkey_id),
|
||||||
|
Some(&session.access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
app.with_conn(move |conn| {
|
||||||
|
use backend::schema::user_passkeys::dsl as passkey_dsl;
|
||||||
|
|
||||||
|
let record = passkey_dsl::user_passkeys
|
||||||
|
.find(passkey_id)
|
||||||
|
.first::<UserPasskey>(conn)?;
|
||||||
|
assert!(record.revoked_at.is_some());
|
||||||
|
assert_eq!(record.revoked_reason.as_deref(), Some("lost"));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_passkey_prevents_last() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let password = "secret";
|
||||||
|
let user_id = app.insert_user("passkey-guard", password, "admin").await?;
|
||||||
|
let first_id = app.insert_passkey(user_id, Some("Key A")).await?;
|
||||||
|
let last_id = app.insert_passkey(user_id, Some("Key B")).await?;
|
||||||
|
let (session, _) = login_with_session(&app, "passkey-guard", password).await?;
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.delete(
|
||||||
|
&format!("/api/profile/passkeys/{}", first_id),
|
||||||
|
Some(&session.access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let block_response = app
|
||||||
|
.delete(
|
||||||
|
&format!("/api/profile/passkeys/{}", last_id),
|
||||||
|
Some(&session.access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(block_response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fake_register_credential() -> RegisterPublicKeyCredential {
|
||||||
|
RegisterPublicKeyCredential {
|
||||||
|
id: "fake-passkey".to_string(),
|
||||||
|
raw_id: vec![1, 2, 3, 4].into(),
|
||||||
|
response: AuthenticatorAttestationResponseRaw {
|
||||||
|
attestation_object: vec![5, 6, 7, 8].into(),
|
||||||
|
client_data_json: vec![9, 10, 11, 12].into(),
|
||||||
|
transports: None,
|
||||||
|
},
|
||||||
|
type_: "public-key".to_string(),
|
||||||
|
extensions: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fake_authentication_credential() -> PublicKeyCredential {
|
||||||
|
PublicKeyCredential {
|
||||||
|
id: "fake-auth".to_string(),
|
||||||
|
raw_id: vec![1, 2, 3].into(),
|
||||||
|
response: AuthenticatorAssertionResponseRaw {
|
||||||
|
authenticator_data: vec![4, 5, 6].into(),
|
||||||
|
client_data_json: vec![7, 8, 9].into(),
|
||||||
|
signature: vec![10, 11, 12].into(),
|
||||||
|
user_handle: None,
|
||||||
|
},
|
||||||
|
extensions: Default::default(),
|
||||||
|
type_: "public-key".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn login_rejects_invalid_password() -> Result<()> {
|
async fn login_rejects_invalid_password() -> Result<()> {
|
||||||
let _lock = acquire_db_lock().await;
|
let _lock = acquire_db_lock().await;
|
||||||
@@ -91,10 +407,10 @@ async fn login_rejects_invalid_password() -> Result<()> {
|
|||||||
|
|
||||||
let payload = json!({ "username": "robin", "password": "wrong" });
|
let payload = json!({ "username": "robin", "password": "wrong" });
|
||||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
let body = body_to_vec(response.into_body()).await?;
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
let err: ErrorResponse = serde_json::from_slice(&body)?;
|
let err: ErrorResponse = serde_json::from_slice(&body)?;
|
||||||
assert_eq!(err.error, "unauthorized");
|
assert_eq!(err.error, "password authentication is no longer supported");
|
||||||
|
|
||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -117,7 +433,7 @@ async fn refresh_rotates_refresh_token() -> Result<()> {
|
|||||||
let new_cookie = extract_refresh_cookie(response.headers())?;
|
let new_cookie = extract_refresh_cookie(response.headers())?;
|
||||||
let body = body_to_vec(response.into_body()).await?;
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
let refreshed: LoginResponse = serde_json::from_slice(&body)?;
|
let refreshed: LoginResponse = serde_json::from_slice(&body)?;
|
||||||
assert_eq!(refreshed.tenant.slug, login.tenant.slug);
|
assert_eq!(refreshed.tenant.name, login.tenant.name);
|
||||||
|
|
||||||
let me_response = app
|
let me_response = app
|
||||||
.get("/api/auth/me", Some(&refreshed.access_token))
|
.get("/api/auth/me", Some(&refreshed.access_token))
|
||||||
@@ -190,14 +506,15 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()
|
|||||||
let password = "multipass";
|
let password = "multipass";
|
||||||
let user_id = app.insert_user("multipass", password, "admin").await?;
|
let user_id = app.insert_user("multipass", password, "admin").await?;
|
||||||
|
|
||||||
let secondary_slug = "secondary".to_string();
|
let secondary_name = "secondary".to_string();
|
||||||
let slug_for_insert = secondary_slug.clone();
|
let name_for_insert = secondary_name.clone();
|
||||||
let secondary_id = Uuid::new_v4();
|
let secondary_id = Uuid::new_v4();
|
||||||
app.with_conn(move |conn| {
|
app.with_conn(move |conn| {
|
||||||
diesel::insert_into(tenants::table)
|
diesel::insert_into(tenants::table)
|
||||||
.values((
|
.values((
|
||||||
tenants::id.eq(secondary_id),
|
tenants::id.eq(secondary_id),
|
||||||
tenants::slug.eq(&slug_for_insert),
|
tenants::name.eq(&name_for_insert),
|
||||||
|
tenants::status.eq(TenantStatus::Active),
|
||||||
))
|
))
|
||||||
.execute(conn)?;
|
.execute(conn)?;
|
||||||
|
|
||||||
@@ -205,7 +522,6 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()
|
|||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
user_id,
|
user_id,
|
||||||
tenant_id: secondary_id,
|
tenant_id: secondary_id,
|
||||||
role: "admin".to_string(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(user_memberships::table)
|
diesel::insert_into(user_memberships::table)
|
||||||
@@ -215,39 +531,36 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()
|
|||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let payload = json!({ "username": "multipass", "password": password });
|
let (login, refresh_cookie) = login_with_session(&app, "multipass", password).await?;
|
||||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
let tenants_response = app
|
||||||
let body = body_to_vec(response.into_body()).await?;
|
.get("/api/auth/tenants", Some(&login.access_token))
|
||||||
let selection: TenantSelectionResponse = serde_json::from_slice(&body)?;
|
.await?;
|
||||||
assert!(selection.tenants.len() >= 2);
|
assert_eq!(tenants_response.status(), StatusCode::OK);
|
||||||
let secondary = selection
|
let tenants_body = body_to_vec(tenants_response.into_body()).await?;
|
||||||
|
let tenant_list: TenantListResponse = serde_json::from_slice(&tenants_body)?;
|
||||||
|
assert!(tenant_list.tenants.len() >= 2);
|
||||||
|
|
||||||
|
let secondary = tenant_list
|
||||||
.tenants
|
.tenants
|
||||||
.iter()
|
.iter()
|
||||||
.find(|tenant| tenant.slug == secondary_slug)
|
.find(|tenant| tenant.name == secondary_name)
|
||||||
.map(|t| t.tenant_id)
|
.map(|t| t.id)
|
||||||
.context("secondary tenant missing from selection")?;
|
.context("secondary tenant missing from listing")?;
|
||||||
|
|
||||||
let select_response = app
|
let select_response = app
|
||||||
.post_json(
|
.post_json_with_cookie(
|
||||||
"/api/auth/select-tenant",
|
"/api/auth/select-tenant",
|
||||||
&json!({ "tenant_id": secondary }),
|
&json!({ "tenant_id": secondary }),
|
||||||
Some(&selection.access_token),
|
Some(&login.access_token),
|
||||||
|
Some(&refresh_cookie),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
assert_eq!(select_response.status(), StatusCode::OK);
|
assert_eq!(select_response.status(), StatusCode::OK);
|
||||||
let session_cookie = extract_refresh_cookie(select_response.headers())?;
|
|
||||||
let select_body = body_to_vec(select_response.into_body()).await?;
|
let select_body = body_to_vec(select_response.into_body()).await?;
|
||||||
let login: LoginResponse = serde_json::from_slice(&select_body)?;
|
let rotated: LoginResponse = serde_json::from_slice(&select_body)?;
|
||||||
assert_eq!(login.tenant.slug, secondary_slug);
|
assert_eq!(rotated.tenant.id, secondary);
|
||||||
|
assert_eq!(rotated.tenant.name, secondary_name);
|
||||||
let me_response = app.get("/api/auth/me", Some(&login.access_token)).await?;
|
|
||||||
assert_eq!(me_response.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
let refresh_response = app
|
|
||||||
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&session_cookie))
|
|
||||||
.await?;
|
|
||||||
assert_eq!(refresh_response.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -256,16 +569,60 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()
|
|||||||
async fn login_with_session(
|
async fn login_with_session(
|
||||||
app: &TestApp,
|
app: &TestApp,
|
||||||
username: &str,
|
username: &str,
|
||||||
password: &str,
|
_password: &str,
|
||||||
) -> Result<(LoginResponse, String)> {
|
) -> Result<(LoginResponse, String)> {
|
||||||
let payload = json!({ "username": username, "password": password });
|
let username = username.to_string();
|
||||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
let state = app.state.clone();
|
||||||
ensure_status(&response, StatusCode::OK)?;
|
app.with_conn(move |conn| {
|
||||||
let refresh_cookie = extract_refresh_cookie(response.headers())?;
|
use backend::schema::user_memberships::dsl as memberships_dsl;
|
||||||
let body = body_to_vec(response.into_body()).await?;
|
use backend::schema::users::dsl as users_dsl;
|
||||||
let login: LoginResponse = serde_json::from_slice(&body)
|
|
||||||
.map_err(|_| anyhow!("expected login response with session"))?;
|
let user: backend::models::User = users_dsl::users
|
||||||
Ok((login, refresh_cookie))
|
.filter(users_dsl::username.eq(&username))
|
||||||
|
.first(conn)?;
|
||||||
|
|
||||||
|
let membership: backend::models::UserMembership = memberships_dsl::user_memberships
|
||||||
|
.filter(memberships_dsl::user_id.eq(user.id))
|
||||||
|
.first(conn)?;
|
||||||
|
|
||||||
|
let tenant: backend::models::Tenant =
|
||||||
|
tenants::table.find(membership.tenant_id).first(conn)?;
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let access_token = state
|
||||||
|
.jwt
|
||||||
|
.generate_token(user.id, tenant.id, &user.username)
|
||||||
|
.map_err(|err| anyhow!(err))?;
|
||||||
|
|
||||||
|
let refresh_value = generate_refresh_token();
|
||||||
|
let refresh_hash = hash_refresh_token(&refresh_value);
|
||||||
|
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||||
|
|
||||||
|
let new_refresh = NewRefreshToken {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
user_id: user.id,
|
||||||
|
token_hash: refresh_hash,
|
||||||
|
issued_at: now.naive_utc(),
|
||||||
|
expires_at: refresh_expires_at.naive_utc(),
|
||||||
|
tenant_id: tenant.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::insert_into(refresh_tokens::table)
|
||||||
|
.values(&new_refresh)
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
let login = LoginResponse {
|
||||||
|
access_token,
|
||||||
|
tenant: LoginTenant {
|
||||||
|
id: tenant.id,
|
||||||
|
name: tenant.name.clone(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let cookie = format!("refresh_token={refresh_value}");
|
||||||
|
Ok((login, cookie))
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
|
fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
|
||||||
@@ -282,14 +639,14 @@ fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
|
|||||||
Ok(cookie)
|
Ok(cookie)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_status(response: &hyper::Response<Body>, expected: StatusCode) -> Result<()> {
|
fn generate_refresh_token() -> String {
|
||||||
if response.status() == expected {
|
let mut bytes = [0u8; 32];
|
||||||
Ok(())
|
OsRng.fill_bytes(&mut bytes);
|
||||||
} else {
|
hex::encode(bytes)
|
||||||
Err(anyhow!(
|
}
|
||||||
"unexpected status: got {}, expected {}",
|
|
||||||
response.status(),
|
fn hash_refresh_token(value: &str) -> String {
|
||||||
expected
|
let mut hasher = Sha256::new();
|
||||||
))
|
hasher.update(value.as_bytes());
|
||||||
}
|
hex::encode(hasher.finalize())
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-92
@@ -6,15 +6,20 @@ use std::time::Duration;
|
|||||||
use anyhow::{anyhow, ensure, Context, Result};
|
use anyhow::{anyhow, ensure, Context, Result};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{header, Method, Request, StatusCode};
|
use axum::http::{header, Method, Request};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use backend::auth::jwt::JwtService;
|
use backend::auth::jwt::JwtService;
|
||||||
use backend::config::AppConfig;
|
use backend::config::AppConfig;
|
||||||
use backend::db::{self, PgPool};
|
use backend::db::{self, PgPool};
|
||||||
use backend::models::{Job, NewUser, NewUserMembership, Tenant};
|
use backend::models::{
|
||||||
|
Job, NewRefreshToken, NewUser, NewUserMembership, NewUserPasskey, Tenant, TenantStatus, User,
|
||||||
|
UserMembership,
|
||||||
|
};
|
||||||
use backend::routes;
|
use backend::routes;
|
||||||
|
use backend::schema::refresh_tokens::dsl as refresh_dsl;
|
||||||
use backend::state::AppState;
|
use backend::state::AppState;
|
||||||
use backend::storage::ObjectStorage;
|
use backend::storage::ObjectStorage;
|
||||||
|
use chrono::{Duration as ChronoDuration, Utc};
|
||||||
use diesel::connection::SimpleConnection;
|
use diesel::connection::SimpleConnection;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use diesel::OptionalExtension;
|
use diesel::OptionalExtension;
|
||||||
@@ -23,8 +28,10 @@ use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
|||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json;
|
use serde_json::{self, json};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -33,6 +40,8 @@ const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
|||||||
|
|
||||||
static DB_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
|
static DB_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
|
||||||
|
|
||||||
|
const TEST_TENANT_NAME: &str = "test_tenant";
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct StoredObject {
|
pub struct StoredObject {
|
||||||
@@ -140,7 +149,11 @@ impl TestApp {
|
|||||||
s3_bucket: "test-bucket".to_string(),
|
s3_bucket: "test-bucket".to_string(),
|
||||||
quickwit_endpoint: None,
|
quickwit_endpoint: None,
|
||||||
quickwit_index: None,
|
quickwit_index: None,
|
||||||
default_tenant_slug: "admin".to_string(),
|
worker_max_document_bytes: 200 * 1024 * 1024,
|
||||||
|
upload_body_limit_bytes: 128 * 1024 * 1024,
|
||||||
|
webauthn_rp_id: Some("localhost".to_string()),
|
||||||
|
webauthn_origin: Some("http://localhost".to_string()),
|
||||||
|
webauthn_rp_name: "Papercrate".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
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)?;
|
||||||
@@ -186,10 +199,11 @@ impl TestApp {
|
|||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub async fn storage_key_for(&self, key: &str) -> Result<String> {
|
pub async fn storage_key_for(&self, key: &str) -> Result<String> {
|
||||||
|
self.ensure_default_tenant().await?;
|
||||||
let tenant = self
|
let tenant = self
|
||||||
.state
|
.state
|
||||||
.tenants
|
.tenants
|
||||||
.get_by_slug(&self.state.config.default_tenant_slug)
|
.get_by_name(TEST_TENANT_NAME)
|
||||||
.map_err(|err| anyhow!("default tenant not found: {:?}", err))?;
|
.map_err(|err| anyhow!("default tenant not found: {:?}", err))?;
|
||||||
let root = tenant
|
let root = tenant
|
||||||
.storage_root
|
.storage_root
|
||||||
@@ -198,21 +212,13 @@ impl TestApp {
|
|||||||
Ok(format!("{}{}", root, key))
|
Ok(format!("{}{}", root, key))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn insert_user(&self, username: &str, password: &str, role: &str) -> Result<Uuid> {
|
pub async fn insert_user(&self, username: &str, _password: &str, _role: &str) -> Result<Uuid> {
|
||||||
let username = username.to_string();
|
let username = username.to_string();
|
||||||
let password = password.to_string();
|
let tenant_id = self.ensure_default_tenant().await?;
|
||||||
let role = role.to_string();
|
|
||||||
let tenant_id = self
|
|
||||||
.state
|
|
||||||
.tenants
|
|
||||||
.tenant_id_for_slug(&self.state.config.default_tenant_slug)
|
|
||||||
.map_err(|err| anyhow!("default tenant not found: {:?}", err))?;
|
|
||||||
self.with_conn(move |conn| {
|
self.with_conn(move |conn| {
|
||||||
let password_hash = hash_password(&password)?;
|
|
||||||
let user = NewUser {
|
let user = NewUser {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
username,
|
username,
|
||||||
password_hash,
|
|
||||||
};
|
};
|
||||||
diesel::insert_into(backend::schema::users::table)
|
diesel::insert_into(backend::schema::users::table)
|
||||||
.values(&user)
|
.values(&user)
|
||||||
@@ -223,7 +229,6 @@ impl TestApp {
|
|||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
role,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(backend::schema::user_memberships::table)
|
diesel::insert_into(backend::schema::user_memberships::table)
|
||||||
@@ -235,14 +240,42 @@ impl TestApp {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn insert_passkey(&self, user_id: Uuid, nickname: Option<&str>) -> Result<Uuid> {
|
||||||
|
let passkey_id = Uuid::new_v4();
|
||||||
|
let nickname = nickname.map(|value| value.to_string());
|
||||||
|
self.with_conn(move |conn| {
|
||||||
|
let credential_id = passkey_id.as_bytes().to_vec();
|
||||||
|
let public_key = passkey_id.as_bytes().iter().copied().collect::<Vec<u8>>();
|
||||||
|
let passkey = NewUserPasskey {
|
||||||
|
id: passkey_id,
|
||||||
|
user_id,
|
||||||
|
credential_id,
|
||||||
|
public_key,
|
||||||
|
credential: json!({ "dummy": passkey_id.to_string() }),
|
||||||
|
sign_count: 0,
|
||||||
|
transports: vec![Some("usb".to_string())],
|
||||||
|
aaguid: None,
|
||||||
|
nickname,
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::insert_into(backend::schema::user_passkeys::table)
|
||||||
|
.values(&passkey)
|
||||||
|
.execute(conn)
|
||||||
|
.context("failed to insert passkey")?;
|
||||||
|
|
||||||
|
Ok(passkey_id)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
async fn ensure_default_tenant(&self) -> Result<Uuid> {
|
async fn ensure_default_tenant(&self) -> Result<Uuid> {
|
||||||
let slug_value = self.state.config.default_tenant_slug.clone();
|
let name_value = TEST_TENANT_NAME.to_string();
|
||||||
let quickwit_enabled = self.state.config.quickwit_endpoint.is_some();
|
let quickwit_enabled = self.state.config.quickwit_endpoint.is_some();
|
||||||
self.with_conn(move |conn| {
|
self.with_conn(move |conn| {
|
||||||
use backend::schema::tenants::dsl as tenants_dsl;
|
use backend::schema::tenants::dsl as tenants_dsl;
|
||||||
|
|
||||||
let existing = tenants_dsl::tenants
|
let existing = tenants_dsl::tenants
|
||||||
.filter(tenants_dsl::slug.eq(&slug_value))
|
.filter(tenants_dsl::name.eq(&name_value))
|
||||||
.first::<Tenant>(conn)
|
.first::<Tenant>(conn)
|
||||||
.optional()
|
.optional()
|
||||||
.context("failed to load default tenant")?;
|
.context("failed to load default tenant")?;
|
||||||
@@ -274,9 +307,10 @@ impl TestApp {
|
|||||||
diesel::insert_into(tenants_dsl::tenants)
|
diesel::insert_into(tenants_dsl::tenants)
|
||||||
.values((
|
.values((
|
||||||
tenants_dsl::id.eq(new_id),
|
tenants_dsl::id.eq(new_id),
|
||||||
tenants_dsl::slug.eq(&slug_value),
|
tenants_dsl::name.eq(&name_value),
|
||||||
tenants_dsl::storage_root.eq(Some(root)),
|
tenants_dsl::storage_root.eq(Some(root)),
|
||||||
tenants_dsl::quickwit_index.eq(quickwit_value),
|
tenants_dsl::quickwit_index.eq(quickwit_value),
|
||||||
|
tenants_dsl::status.eq(TenantStatus::Active),
|
||||||
))
|
))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.context("failed to insert default tenant")?;
|
.context("failed to insert default tenant")?;
|
||||||
@@ -289,80 +323,59 @@ impl TestApp {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login_token(&self, username: &str, password: &str) -> Result<String> {
|
pub async fn login_token(&self, username: &str, _password: &str) -> Result<String> {
|
||||||
#[derive(Serialize)]
|
let (access_token, _, _) = self.create_session(username).await?;
|
||||||
struct LoginPayload<'a> {
|
Ok(access_token)
|
||||||
username: &'a str,
|
}
|
||||||
password: &'a str,
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = self
|
pub async fn create_session(&self, username: &str) -> Result<(String, String, Uuid)> {
|
||||||
.post_json(
|
let username = username.to_string();
|
||||||
"/api/auth/login",
|
let state = self.state.clone();
|
||||||
&LoginPayload { username, password },
|
self.with_conn(move |conn| {
|
||||||
None,
|
use backend::schema::tenants::dsl as tenants_dsl;
|
||||||
)
|
use backend::schema::user_memberships::dsl as memberships_dsl;
|
||||||
.await?;
|
use backend::schema::users::dsl as users_dsl;
|
||||||
|
|
||||||
ensure!(
|
let user: User = users_dsl::users
|
||||||
response.status() == StatusCode::OK,
|
.filter(users_dsl::username.eq(&username))
|
||||||
"login failed with status {}",
|
.first(conn)?;
|
||||||
response.status()
|
|
||||||
);
|
|
||||||
|
|
||||||
let body = body_to_vec(response.into_body()).await?;
|
let membership: UserMembership = memberships_dsl::user_memberships
|
||||||
#[derive(Deserialize)]
|
.filter(memberships_dsl::user_id.eq(user.id))
|
||||||
struct LoginResponse {
|
.first(conn)?;
|
||||||
access_token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(parsed) = serde_json::from_slice::<LoginResponse>(&body) {
|
let tenant: Tenant = tenants_dsl::tenants
|
||||||
return Ok(parsed.access_token);
|
.find(membership.tenant_id)
|
||||||
}
|
.first(conn)?;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
let now = Utc::now();
|
||||||
struct TenantSummary {
|
let access_token = state
|
||||||
tenant_id: Uuid,
|
.jwt
|
||||||
_slug: String,
|
.generate_token(user.id, tenant.id, &user.username)
|
||||||
}
|
.map_err(|err| anyhow!(err))?;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
let refresh_value = generate_refresh_token();
|
||||||
struct TenantSelectionResponse {
|
let refresh_hash = hash_refresh_token(&refresh_value);
|
||||||
access_token: String,
|
let refresh_expires_at =
|
||||||
tenants: Vec<TenantSummary>,
|
now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||||
}
|
|
||||||
|
|
||||||
let selection: TenantSelectionResponse = serde_json::from_slice(&body)?;
|
let new_refresh = NewRefreshToken {
|
||||||
ensure!(
|
id: Uuid::new_v4(),
|
||||||
!selection.tenants.is_empty(),
|
user_id: user.id,
|
||||||
"login returned no tenant options",
|
token_hash: refresh_hash,
|
||||||
);
|
issued_at: now.naive_utc(),
|
||||||
|
expires_at: refresh_expires_at.naive_utc(),
|
||||||
|
tenant_id: tenant.id,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Serialize)]
|
diesel::insert_into(refresh_dsl::refresh_tokens)
|
||||||
struct SelectTenantPayload {
|
.values(&new_refresh)
|
||||||
tenant_id: Uuid,
|
.execute(conn)?;
|
||||||
}
|
|
||||||
|
|
||||||
let target_tenant = selection.tenants[0].tenant_id;
|
let cookie = format!("refresh_token={refresh_value}");
|
||||||
let select_response = self
|
Ok((access_token, cookie, tenant.id))
|
||||||
.post_json(
|
})
|
||||||
"/api/auth/select-tenant",
|
.await
|
||||||
&SelectTenantPayload {
|
|
||||||
tenant_id: target_tenant,
|
|
||||||
},
|
|
||||||
Some(&selection.access_token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
ensure!(
|
|
||||||
select_response.status() == StatusCode::OK,
|
|
||||||
"tenant selection failed with status {}",
|
|
||||||
select_response.status()
|
|
||||||
);
|
|
||||||
|
|
||||||
let select_body = body_to_vec(select_response.into_body()).await?;
|
|
||||||
let parsed: LoginResponse = serde_json::from_slice(&select_body)?;
|
|
||||||
Ok(parsed.access_token)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -717,13 +730,14 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn hash_password(password: &str) -> Result<String> {
|
fn generate_refresh_token() -> String {
|
||||||
use argon2::password_hash::{PasswordHasher, SaltString};
|
let mut bytes = [0u8; 32];
|
||||||
use argon2::Argon2;
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
hex::encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
fn hash_refresh_token(value: &str) -> String {
|
||||||
Ok(Argon2::default()
|
let mut hasher = Sha256::new();
|
||||||
.hash_password(password.as_bytes(), &salt)
|
hasher.update(value.as_bytes());
|
||||||
.map_err(|err| anyhow!("failed to hash password: {err}"))?
|
hex::encode(hasher.finalize())
|
||||||
.to_string())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -620,7 +620,7 @@ async fn bulk_move_documents_to_folder() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn bulk_update_tags_for_selection() -> Result<()> {
|
async fn bulk_add_tags_for_selection() -> Result<()> {
|
||||||
let _lock = acquire_db_lock().await;
|
let _lock = acquire_db_lock().await;
|
||||||
let app = TestApp::new().await?;
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
@@ -755,6 +755,84 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
|
|||||||
assert!(labels.contains(&"Review"));
|
assert!(labels.contains(&"Review"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bulk_remove_tags_from_selection() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let password = "bulktagremove";
|
||||||
|
app.insert_user("tagrem", password, "admin").await?;
|
||||||
|
let token = app.login_token("tagrem", password).await?;
|
||||||
|
|
||||||
|
let first = app
|
||||||
|
.upload_document(
|
||||||
|
"/api/documents",
|
||||||
|
"meeting-notes.txt",
|
||||||
|
"text/plain",
|
||||||
|
b"notes",
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let first_body = body_to_vec(first.into_body()).await?;
|
||||||
|
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
|
||||||
|
|
||||||
|
let second = app
|
||||||
|
.upload_document(
|
||||||
|
"/api/documents",
|
||||||
|
"draft.txt",
|
||||||
|
"text/plain",
|
||||||
|
b"draft",
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let second_body = body_to_vec(second.into_body()).await?;
|
||||||
|
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
|
||||||
|
|
||||||
|
let urgent_tag = app
|
||||||
|
.post_json(
|
||||||
|
"/api/tags",
|
||||||
|
&CreateTagPayload {
|
||||||
|
label: "Urgent",
|
||||||
|
color: None,
|
||||||
|
},
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let urgent_body = body_to_vec(urgent_tag.into_body()).await?;
|
||||||
|
let urgent: TagResponse = serde_json::from_slice(&urgent_body)?;
|
||||||
|
|
||||||
|
let review_tag = app
|
||||||
|
.post_json(
|
||||||
|
"/api/tags",
|
||||||
|
&CreateTagPayload {
|
||||||
|
label: "Review",
|
||||||
|
color: None,
|
||||||
|
},
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let review_body = body_to_vec(review_tag.into_body()).await?;
|
||||||
|
let review: TagResponse = serde_json::from_slice(&review_body)?;
|
||||||
|
|
||||||
|
let seed_resp = app
|
||||||
|
.post_json(
|
||||||
|
"/api/documents/bulk/tags",
|
||||||
|
&BulkTagRequest {
|
||||||
|
document_ids: &[first_detail.document.id, second_detail.document.id],
|
||||||
|
tag_ids: &[urgent.id, review.id],
|
||||||
|
action: "add",
|
||||||
|
},
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(seed_resp.status().is_success());
|
||||||
|
|
||||||
let remove_resp = app
|
let remove_resp = app
|
||||||
.post_json(
|
.post_json(
|
||||||
"/api/documents/bulk/tags",
|
"/api/documents/bulk/tags",
|
||||||
@@ -766,17 +844,11 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
{
|
assert!(remove_resp.status().is_success());
|
||||||
let status = remove_resp.status();
|
|
||||||
assert!(
|
|
||||||
status == StatusCode::OK
|
|
||||||
|| status == StatusCode::CREATED
|
|
||||||
|| status == StatusCode::NO_CONTENT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let remove_body = body_to_vec(remove_resp.into_body()).await?;
|
let remove_body = body_to_vec(remove_resp.into_body()).await?;
|
||||||
let remove_result: BulkTagResult = serde_json::from_slice(&remove_body)?;
|
let remove_result: BulkTagResult = serde_json::from_slice(&remove_body)?;
|
||||||
assert_eq!(remove_result.removed, 2);
|
assert_eq!(remove_result.removed, 2);
|
||||||
|
assert_eq!(remove_result.added, 0);
|
||||||
|
|
||||||
for doc_id in [&first_detail.document.id, &second_detail.document.id] {
|
for doc_id in [&first_detail.document.id, &second_detail.document.id] {
|
||||||
let refreshed = app
|
let refreshed = app
|
||||||
@@ -794,6 +866,23 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
|
|||||||
assert!(labels.contains(&"Review"));
|
assert!(labels.contains(&"Review"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let idempotent_resp = app
|
||||||
|
.post_json(
|
||||||
|
"/api/documents/bulk/tags",
|
||||||
|
&BulkTagRequest {
|
||||||
|
document_ids: &[first_detail.document.id, second_detail.document.id],
|
||||||
|
tag_ids: &[urgent.id],
|
||||||
|
action: "remove",
|
||||||
|
},
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(idempotent_resp.status().is_success());
|
||||||
|
let idempotent_body = body_to_vec(idempotent_resp.into_body()).await?;
|
||||||
|
let idempotent_result: BulkTagResult = serde_json::from_slice(&idempotent_body)?;
|
||||||
|
assert_eq!(idempotent_result.removed, 0);
|
||||||
|
assert_eq!(idempotent_result.added, 0);
|
||||||
|
|
||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ async fn folder_move_and_delete_flow() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
assert_eq!(folder_resp.status(), StatusCode::OK);
|
assert_eq!(folder_resp.status(), StatusCode::CREATED);
|
||||||
let folder_body = body_to_vec(folder_resp.into_body()).await?;
|
let folder_body = body_to_vec(folder_resp.into_body()).await?;
|
||||||
let folder: FolderResponse = serde_json::from_slice(&folder_body)?;
|
let folder: FolderResponse = serde_json::from_slice(&folder_body)?;
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ async fn update_folder_parent_to_root() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
assert_eq!(parent_resp.status(), StatusCode::OK);
|
assert_eq!(parent_resp.status(), StatusCode::CREATED);
|
||||||
let parent_body = body_to_vec(parent_resp.into_body()).await?;
|
let parent_body = body_to_vec(parent_resp.into_body()).await?;
|
||||||
let parent: FolderResponse = serde_json::from_slice(&parent_body)?;
|
let parent: FolderResponse = serde_json::from_slice(&parent_body)?;
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ async fn update_folder_parent_to_root() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
assert_eq!(child_resp.status(), StatusCode::OK);
|
assert_eq!(child_resp.status(), StatusCode::CREATED);
|
||||||
let child_body = body_to_vec(child_resp.into_body()).await?;
|
let child_body = body_to_vec(child_resp.into_body()).await?;
|
||||||
let child: FolderResponse = serde_json::from_slice(&child_body)?;
|
let child: FolderResponse = serde_json::from_slice(&child_body)?;
|
||||||
|
|
||||||
@@ -310,14 +310,14 @@ async fn create_folder_is_idempotent() -> Result<()> {
|
|||||||
let first_resp = app
|
let first_resp = app
|
||||||
.post_json("/api/folders", &payload, Some(&token))
|
.post_json("/api/folders", &payload, Some(&token))
|
||||||
.await?;
|
.await?;
|
||||||
assert!(first_resp.status().is_success());
|
assert_eq!(first_resp.status(), StatusCode::CREATED);
|
||||||
let first_body = body_to_vec(first_resp.into_body()).await?;
|
let first_body = body_to_vec(first_resp.into_body()).await?;
|
||||||
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
|
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
|
||||||
|
|
||||||
let second_resp = app
|
let second_resp = app
|
||||||
.post_json("/api/folders", &payload, Some(&token))
|
.post_json("/api/folders", &payload, Some(&token))
|
||||||
.await?;
|
.await?;
|
||||||
assert!(second_resp.status().is_success());
|
assert_eq!(second_resp.status(), StatusCode::OK);
|
||||||
let second_body = body_to_vec(second_resp.into_body()).await?;
|
let second_body = body_to_vec(second_resp.into_body()).await?;
|
||||||
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
|
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
|
||||||
|
|
||||||
@@ -424,7 +424,7 @@ async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
assert_eq!(parent_resp.status(), StatusCode::OK);
|
assert_eq!(parent_resp.status(), StatusCode::CREATED);
|
||||||
let parent_body = body_to_vec(parent_resp.into_body()).await?;
|
let parent_body = body_to_vec(parent_resp.into_body()).await?;
|
||||||
let parent: FolderResponse = serde_json::from_slice(&parent_body)?;
|
let parent: FolderResponse = serde_json::from_slice(&parent_body)?;
|
||||||
|
|
||||||
@@ -438,7 +438,7 @@ async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
assert_eq!(child_resp.status(), StatusCode::OK);
|
assert_eq!(child_resp.status(), StatusCode::CREATED);
|
||||||
let child_body = body_to_vec(child_resp.into_body()).await?;
|
let child_body = body_to_vec(child_resp.into_body()).await?;
|
||||||
let child: FolderResponse = serde_json::from_slice(&child_body)?;
|
let child: FolderResponse = serde_json::from_slice(&child_body)?;
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ mod common;
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use backend::models::{NewUser, NewUserMembership, Tag};
|
use backend::models::{NewUser, NewUserMembership, Tag, TenantStatus};
|
||||||
use backend::schema::{
|
use backend::schema::{
|
||||||
tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl,
|
tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl,
|
||||||
users::dsl as users_dsl,
|
users::dsl as users_dsl,
|
||||||
};
|
};
|
||||||
use common::{acquire_db_lock, body_to_vec, hash_password, TestApp};
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -204,23 +204,20 @@ async fn tags_are_isolated_between_tenants() -> Result<()> {
|
|||||||
|
|
||||||
let tenant_b_id = Uuid::new_v4();
|
let tenant_b_id = Uuid::new_v4();
|
||||||
let user_b_id = Uuid::new_v4();
|
let user_b_id = Uuid::new_v4();
|
||||||
let password_b = "tenant-b";
|
|
||||||
|
|
||||||
app.with_conn(move |conn| {
|
app.with_conn(move |conn| {
|
||||||
let storage_root = format!("test-tenants/{tenant_b_id}/");
|
let storage_root = format!("test-tenants/{tenant_b_id}/");
|
||||||
diesel::insert_into(tenants_dsl::tenants)
|
diesel::insert_into(tenants_dsl::tenants)
|
||||||
.values((
|
.values((
|
||||||
tenants_dsl::id.eq(tenant_b_id),
|
tenants_dsl::id.eq(tenant_b_id),
|
||||||
tenants_dsl::slug.eq("tenant-b"),
|
tenants_dsl::name.eq("tenant-b"),
|
||||||
tenants_dsl::storage_root.eq(Some(storage_root)),
|
tenants_dsl::storage_root.eq(Some(storage_root)),
|
||||||
|
tenants_dsl::status.eq(TenantStatus::Active),
|
||||||
))
|
))
|
||||||
.execute(conn)?;
|
.execute(conn)?;
|
||||||
|
|
||||||
let password_hash = hash_password(password_b)?;
|
|
||||||
let new_user = NewUser {
|
let new_user = NewUser {
|
||||||
id: user_b_id,
|
id: user_b_id,
|
||||||
username: "bob".to_string(),
|
username: "bob".to_string(),
|
||||||
password_hash,
|
|
||||||
};
|
};
|
||||||
diesel::insert_into(users_dsl::users)
|
diesel::insert_into(users_dsl::users)
|
||||||
.values(&new_user)
|
.values(&new_user)
|
||||||
@@ -230,7 +227,6 @@ async fn tags_are_isolated_between_tenants() -> Result<()> {
|
|||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
user_id: user_b_id,
|
user_id: user_b_id,
|
||||||
tenant_id: tenant_b_id,
|
tenant_id: tenant_b_id,
|
||||||
role: "admin".to_string(),
|
|
||||||
};
|
};
|
||||||
diesel::insert_into(memberships_dsl::user_memberships)
|
diesel::insert_into(memberships_dsl::user_memberships)
|
||||||
.values(&membership)
|
.values(&membership)
|
||||||
@@ -240,7 +236,7 @@ async fn tags_are_isolated_between_tenants() -> Result<()> {
|
|||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let token_b = app.login_token("bob", password_b).await?;
|
let token_b = app.login_token("bob", "").await?;
|
||||||
|
|
||||||
let create_b = app
|
let create_b = app
|
||||||
.post_json(
|
.post_json(
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ services:
|
|||||||
S3_BUCKET: documents
|
S3_BUCKET: documents
|
||||||
JWT_SECRET: change-me-super-secret
|
JWT_SECRET: change-me-super-secret
|
||||||
QUICKWIT_ENDPOINT: http://quickwit-test:7280
|
QUICKWIT_ENDPOINT: http://quickwit-test:7280
|
||||||
DEFAULT_TENANT_SLUG: admin
|
|
||||||
entrypoint: []
|
entrypoint: []
|
||||||
command: >
|
command: >
|
||||||
/bin/sh -c "
|
/bin/sh -c "
|
||||||
@@ -57,12 +56,17 @@ services:
|
|||||||
else
|
else
|
||||||
papercrate-admin create-tenant admin;
|
papercrate-admin create-tenant admin;
|
||||||
fi &&
|
fi &&
|
||||||
|
ADMIN_TENANT_ID=$(papercrate-admin list-tenants | awk '/^admin / {print $2; exit}') &&
|
||||||
|
if [ -z "$ADMIN_TENANT_ID" ]; then
|
||||||
|
echo 'failed to resolve admin tenant id' >&2;
|
||||||
|
exit 1;
|
||||||
|
fi &&
|
||||||
echo 'Ensuring demo user credentials' &&
|
echo 'Ensuring demo user credentials' &&
|
||||||
(papercrate-admin create-user admin adminadmin || papercrate-admin set-password admin adminadmin) &&
|
(papercrate-admin create-user admin adminadmin || papercrate-admin set-password admin adminadmin) &&
|
||||||
echo 'Ensuring demo membership' &&
|
echo 'Ensuring demo membership' &&
|
||||||
papercrate-admin add-user-to-tenant admin admin admin &&
|
papercrate-admin add-user-to-tenant admin "$ADMIN_TENANT_ID" &&
|
||||||
echo 'Ensuring Quickwit index for admin tenant' &&
|
echo 'Ensuring Quickwit index for admin tenant' &&
|
||||||
papercrate-admin quickwit-create-index admin
|
papercrate-admin quickwit-create-index "$ADMIN_TENANT_ID"
|
||||||
"
|
"
|
||||||
user: root
|
user: root
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|||||||
+7
-3
@@ -80,7 +80,6 @@ services:
|
|||||||
S3_BUCKET: documents
|
S3_BUCKET: documents
|
||||||
JWT_SECRET: change-me-super-secret
|
JWT_SECRET: change-me-super-secret
|
||||||
QUICKWIT_ENDPOINT: http://quickwit:7280
|
QUICKWIT_ENDPOINT: http://quickwit:7280
|
||||||
DEFAULT_TENANT_SLUG: admin
|
|
||||||
entrypoint: []
|
entrypoint: []
|
||||||
command: >
|
command: >
|
||||||
/bin/sh -c "
|
/bin/sh -c "
|
||||||
@@ -92,12 +91,17 @@ services:
|
|||||||
else
|
else
|
||||||
papercrate-admin create-tenant admin;
|
papercrate-admin create-tenant admin;
|
||||||
fi &&
|
fi &&
|
||||||
|
ADMIN_TENANT_ID=$(papercrate-admin list-tenants | awk '/^admin / {print $2; exit}') &&
|
||||||
|
if [ -z "$ADMIN_TENANT_ID" ]; then
|
||||||
|
echo 'failed to resolve admin tenant id' >&2;
|
||||||
|
exit 1;
|
||||||
|
fi &&
|
||||||
echo 'Ensuring demo user credentials' &&
|
echo 'Ensuring demo user credentials' &&
|
||||||
(papercrate-admin create-user admin adminadmin || papercrate-admin set-password admin adminadmin) &&
|
(papercrate-admin create-user admin adminadmin || papercrate-admin set-password admin adminadmin) &&
|
||||||
echo 'Ensuring demo membership' &&
|
echo 'Ensuring demo membership' &&
|
||||||
papercrate-admin add-user-to-tenant admin admin admin &&
|
papercrate-admin add-user-to-tenant admin "$ADMIN_TENANT_ID" &&
|
||||||
echo 'Ensuring Quickwit index for admin tenant' &&
|
echo 'Ensuring Quickwit index for admin tenant' &&
|
||||||
papercrate-admin quickwit-create-index admin
|
papercrate-admin quickwit-create-index "$ADMIN_TENANT_ID"
|
||||||
"
|
"
|
||||||
user: root
|
user: root
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|||||||
+2
-2
@@ -5,8 +5,8 @@ Unless noted otherwise, endpoints below require a valid `Authorization: Bearer <
|
|||||||
|
|
||||||
Authentication
|
Authentication
|
||||||
--------------
|
--------------
|
||||||
- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public). Returns the active tenant as `{ tenant: { id, slug } }`. When multiple tenants are available, the response contains an `access_token` (tenant-selector token) and tenant list instead.
|
- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public). Returns the active tenant as `{ tenant: { id, name } }`. When multiple tenants are available, the response contains an `access_token` (tenant-selector token) and tenant list instead.
|
||||||
- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie). Response also includes the current tenant `{ tenant: { id, slug } }`.
|
- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie). Response also includes the current tenant `{ tenant: { id, name } }`.
|
||||||
- POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie.
|
- POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie.
|
||||||
- GET /api/auth/me - Return the authenticated principal payload.
|
- GET /api/auth/me - Return the authenticated principal payload.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2021": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"plugin:react-hooks/recommended"
|
||||||
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"jsx": true
|
||||||
|
},
|
||||||
|
"ecmaVersion": "latest",
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"react": {
|
||||||
|
"version": "detect"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-use-before-define": [
|
||||||
|
"error",
|
||||||
|
{ "functions": false, "classes": true, "variables": true }
|
||||||
|
],
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
"react/prop-types": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,14 @@ cat <<PROXY >> /etc/nginx/conf.d/default.conf
|
|||||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /download/ {
|
||||||
|
proxy_pass ${API_PROXY_PASS_TRIMMED};
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
}
|
||||||
PROXY
|
PROXY
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Generated
+2615
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "webpack serve --mode development --open",
|
"dev": "webpack serve --mode development --open",
|
||||||
"build": "webpack --mode production",
|
"build": "webpack --mode production",
|
||||||
"lint": "echo \"No linting configured\""
|
"lint": "eslint src --ext .js,.jsx"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
@@ -20,6 +20,9 @@
|
|||||||
"@babel/core": "7.26.0",
|
"@babel/core": "7.26.0",
|
||||||
"@babel/preset-env": "7.26.0",
|
"@babel/preset-env": "7.26.0",
|
||||||
"@babel/preset-react": "7.26.3",
|
"@babel/preset-react": "7.26.3",
|
||||||
|
"eslint": "8.57.0",
|
||||||
|
"eslint-plugin-react": "7.37.1",
|
||||||
|
"eslint-plugin-react-hooks": "4.6.0",
|
||||||
"@svgr/webpack": "8.1.0",
|
"@svgr/webpack": "8.1.0",
|
||||||
"babel-loader": "9.2.1",
|
"babel-loader": "9.2.1",
|
||||||
"css-loader": "7.1.2",
|
"css-loader": "7.1.2",
|
||||||
|
|||||||
+116
-141
@@ -17,12 +17,8 @@ import PreviewZoomOverlay from './detail/PreviewZoomOverlay';
|
|||||||
import { getTagColorStyle } from './utils/colors';
|
import { getTagColorStyle } from './utils/colors';
|
||||||
import './DesktopWorkspace.css';
|
import './DesktopWorkspace.css';
|
||||||
|
|
||||||
const ITEM_WIDTH = 220;
|
|
||||||
const ITEM_HEIGHT = 260;
|
|
||||||
const CANVAS_PADDING = 24;
|
const CANVAS_PADDING = 24;
|
||||||
const ROTATION_RANGE = 7;
|
const ROTATION_RANGE = 7;
|
||||||
const JITTER_X = 26;
|
|
||||||
const JITTER_Y = 32;
|
|
||||||
const DEFAULT_CANVAS_WIDTH = 1024;
|
const DEFAULT_CANVAS_WIDTH = 1024;
|
||||||
const DEFAULT_CANVAS_HEIGHT = 680;
|
const DEFAULT_CANVAS_HEIGHT = 680;
|
||||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||||
@@ -38,6 +34,84 @@ const DEBUG_DRAG = false;
|
|||||||
const DEBUG_FOCUS = true;
|
const DEBUG_FOCUS = true;
|
||||||
const DEBUG_DROP = true;
|
const DEBUG_DROP = true;
|
||||||
|
|
||||||
|
const signedDistance = (ax, ay, bx, by, px, py) => (bx - ax) * (py - ay) - (by - ay) * (px - ax);
|
||||||
|
|
||||||
|
const clipPolygonWithEdge = (subject, edgeStart, edgeEnd) => {
|
||||||
|
if (!subject.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const result = [];
|
||||||
|
let prev = subject[subject.length - 1];
|
||||||
|
let prevInside = signedDistance(edgeStart.x, edgeStart.y, edgeEnd.x, edgeEnd.y, prev.x, prev.y) >= 0;
|
||||||
|
subject.forEach((curr) => {
|
||||||
|
const currInside = signedDistance(edgeStart.x, edgeStart.y, edgeEnd.x, edgeEnd.y, curr.x, curr.y) >= 0;
|
||||||
|
if (currInside !== prevInside) {
|
||||||
|
const dx = curr.x - prev.x;
|
||||||
|
const dy = curr.y - prev.y;
|
||||||
|
const denom = (edgeEnd.x - edgeStart.x) * dy - (edgeEnd.y - edgeStart.y) * dx;
|
||||||
|
if (Math.abs(denom) > 1e-9) {
|
||||||
|
const t = ((edgeStart.x - prev.x) * dy - (edgeStart.y - prev.y) * dx) / denom;
|
||||||
|
result.push({
|
||||||
|
x: edgeStart.x + t * (edgeEnd.x - edgeStart.x),
|
||||||
|
y: edgeStart.y + t * (edgeEnd.y - edgeStart.y),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currInside) {
|
||||||
|
result.push(curr);
|
||||||
|
}
|
||||||
|
prev = curr;
|
||||||
|
prevInside = currInside;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clipPolygon = (subject, clipShape) => {
|
||||||
|
if (!subject.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
let output = subject;
|
||||||
|
let prev = clipShape[clipShape.length - 1];
|
||||||
|
for (let index = 0; index < clipShape.length; index += 1) {
|
||||||
|
const curr = clipShape[index];
|
||||||
|
output = clipPolygonWithEdge(output, prev, curr);
|
||||||
|
if (!output.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
prev = curr;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPointInsideConvex = (point, polygon) => {
|
||||||
|
if (!polygon.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let prev = polygon[polygon.length - 1];
|
||||||
|
for (let index = 0; index < polygon.length; index += 1) {
|
||||||
|
const curr = polygon[index];
|
||||||
|
if (signedDistance(prev.x, prev.y, curr.x, curr.y, point.x, point.y) < -1e-6) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
prev = curr;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const polygonCentroid = (polygon) => {
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
polygon.forEach((point) => {
|
||||||
|
x += point.x;
|
||||||
|
y += point.y;
|
||||||
|
});
|
||||||
|
const count = polygon.length || 1;
|
||||||
|
return {
|
||||||
|
x: x / count,
|
||||||
|
y: y / count,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const readTransferData = (dataTransfer, mimeTypes) => {
|
const readTransferData = (dataTransfer, mimeTypes) => {
|
||||||
if (!dataTransfer) {
|
if (!dataTransfer) {
|
||||||
return null;
|
return null;
|
||||||
@@ -49,10 +123,11 @@ const readTransferData = (dataTransfer, mimeTypes) => {
|
|||||||
if (raw) {
|
if (raw) {
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
} catch (
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-empty
|
if (DEBUG_DROP) {
|
||||||
error
|
console.warn('[skeuo] readTransferData failed for type', type, error);
|
||||||
) {}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
@@ -67,10 +142,9 @@ const parseTagTransferPayload = (event) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw);
|
return JSON.parse(raw);
|
||||||
} catch (
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-empty
|
console.warn('[skeuo] parseTagTransferPayload failed', error);
|
||||||
error
|
}
|
||||||
) {}
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,7 +164,6 @@ const DesktopPreviewCard = ({
|
|||||||
prefetch = 3,
|
prefetch = 3,
|
||||||
onNavigatorSnapshot,
|
onNavigatorSnapshot,
|
||||||
shouldLoad = true,
|
shouldLoad = true,
|
||||||
imageSize = null,
|
|
||||||
}) => {
|
}) => {
|
||||||
const navigator = useAssetNavigator({
|
const navigator = useAssetNavigator({
|
||||||
document: doc,
|
document: doc,
|
||||||
@@ -230,9 +303,6 @@ const generateInitialLayout = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shelfOffset = Math.max(shelfWidth, 0);
|
const shelfOffset = Math.max(shelfWidth, 0);
|
||||||
const usableWidth = Math.max(canvasWidth - shelfOffset - padding * 2, 1);
|
|
||||||
const usableHeight = Math.max(canvasHeight - padding * 2, 1);
|
|
||||||
|
|
||||||
const spacingBuffer = Math.max(minSpacing, 0);
|
const spacingBuffer = Math.max(minSpacing, 0);
|
||||||
const placed = [];
|
const placed = [];
|
||||||
|
|
||||||
@@ -267,7 +337,7 @@ const generateInitialLayout = (
|
|||||||
return best;
|
return best;
|
||||||
};
|
};
|
||||||
|
|
||||||
entries.forEach((entry, index) => {
|
entries.forEach((entry) => {
|
||||||
const width = Number(entry.width) || 0;
|
const width = Number(entry.width) || 0;
|
||||||
const height = Number(entry.height) || 0;
|
const height = Number(entry.height) || 0;
|
||||||
if (!entry.id || width <= 0 || height <= 0) {
|
if (!entry.id || width <= 0 || height <= 0) {
|
||||||
@@ -395,10 +465,9 @@ const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => {
|
|||||||
view = createAssetView(asset);
|
view = createAssetView(asset);
|
||||||
metadata = view.getPrimaryMetadata();
|
metadata = view.getPrimaryMetadata();
|
||||||
}
|
}
|
||||||
} catch (
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-empty
|
console.warn('[skeuo] ensureDocumentSize metadata fetch failed', error);
|
||||||
error
|
}
|
||||||
) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasDimensions(metadata)) {
|
if (!hasDimensions(metadata)) {
|
||||||
@@ -439,7 +508,7 @@ const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => {
|
|||||||
return metadataMap;
|
return metadataMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
const seededRandom = (input) => {
|
function seededRandom(input) {
|
||||||
const text = String(input);
|
const text = String(input);
|
||||||
let hash = 2166136261;
|
let hash = 2166136261;
|
||||||
for (let index = 0; index < text.length; index += 1) {
|
for (let index = 0; index < text.length; index += 1) {
|
||||||
@@ -447,16 +516,18 @@ const seededRandom = (input) => {
|
|||||||
hash = Math.imul(hash, 16777619);
|
hash = Math.imul(hash, 16777619);
|
||||||
}
|
}
|
||||||
return (hash >>> 0) / 4294967295;
|
return (hash >>> 0) / 4294967295;
|
||||||
};
|
}
|
||||||
|
|
||||||
const randomRangeFromSeed = (seedKey, min, max) => {
|
function randomRangeFromSeed(seedKey, min, max) {
|
||||||
const span = max - min;
|
const span = max - min;
|
||||||
if (span <= 0) return min;
|
if (span <= 0) return min;
|
||||||
const seed = seededRandom(seedKey);
|
const seed = seededRandom(seedKey);
|
||||||
return min + seed * span;
|
return min + seed * span;
|
||||||
};
|
}
|
||||||
|
|
||||||
const buildKey = (docId, suffix) => `${docId}::${suffix}`;
|
function buildKey(docId, suffix) {
|
||||||
|
return `${docId}::${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
const clampCardDimensions = (width, height) => {
|
const clampCardDimensions = (width, height) => {
|
||||||
const w = Number(width);
|
const w = Number(width);
|
||||||
@@ -688,10 +759,11 @@ const DesktopWorkspace = ({
|
|||||||
if (DEBUG_FOCUS) {
|
if (DEBUG_FOCUS) {
|
||||||
console.log('[skeuo] focusCanvas: applied focus. activeElement:', document?.activeElement);
|
console.log('[skeuo] focusCanvas: applied focus. activeElement:', document?.activeElement);
|
||||||
}
|
}
|
||||||
} catch (
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-empty
|
if (DEBUG_FOCUS) {
|
||||||
error
|
console.warn('[skeuo] focusTarget failed to focus canvas', error);
|
||||||
) {}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -874,96 +946,6 @@ const DesktopWorkspace = ({
|
|||||||
},
|
},
|
||||||
[resolvePreviewDimensions],
|
[resolvePreviewDimensions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const polygonArea = (points) => {
|
|
||||||
let area = 0;
|
|
||||||
const count = points.length;
|
|
||||||
for (let index = 0; index < count; index += 1) {
|
|
||||||
const next = (index + 1) % count;
|
|
||||||
area += points[index].x * points[next].y;
|
|
||||||
area -= points[next].x * points[index].y;
|
|
||||||
}
|
|
||||||
return area / 2;
|
|
||||||
};
|
|
||||||
|
|
||||||
const signedDistance = (ax, ay, bx, by, px, py) => (bx - ax) * (py - ay) - (by - ay) * (px - ax);
|
|
||||||
|
|
||||||
const clipPolygonWithEdge = (subject, edgeStart, edgeEnd) => {
|
|
||||||
if (!subject.length) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const result = [];
|
|
||||||
let prev = subject[subject.length - 1];
|
|
||||||
let prevInside = signedDistance(edgeStart.x, edgeStart.y, edgeEnd.x, edgeEnd.y, prev.x, prev.y) >= 0;
|
|
||||||
subject.forEach((curr) => {
|
|
||||||
const currInside = signedDistance(edgeStart.x, edgeStart.y, edgeEnd.x, edgeEnd.y, curr.x, curr.y) >= 0;
|
|
||||||
if (currInside !== prevInside) {
|
|
||||||
const dx = curr.x - prev.x;
|
|
||||||
const dy = curr.y - prev.y;
|
|
||||||
const denom = (edgeEnd.x - edgeStart.x) * dy - (edgeEnd.y - edgeStart.y) * dx;
|
|
||||||
if (Math.abs(denom) > 1e-9) {
|
|
||||||
const t = ((edgeStart.x - prev.x) * dy - (edgeStart.y - prev.y) * dx) / denom;
|
|
||||||
result.push({
|
|
||||||
x: edgeStart.x + t * (edgeEnd.x - edgeStart.x),
|
|
||||||
y: edgeStart.y + t * (edgeEnd.y - edgeStart.y),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (currInside) {
|
|
||||||
result.push(curr);
|
|
||||||
}
|
|
||||||
prev = curr;
|
|
||||||
prevInside = currInside;
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const clipPolygon = (subject, clipShape) => {
|
|
||||||
if (!subject.length) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
let output = subject;
|
|
||||||
let prev = clipShape[clipShape.length - 1];
|
|
||||||
for (let index = 0; index < clipShape.length; index += 1) {
|
|
||||||
const curr = clipShape[index];
|
|
||||||
output = clipPolygonWithEdge(output, prev, curr);
|
|
||||||
if (!output.length) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
prev = curr;
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isPointInsideConvex = (point, polygon) => {
|
|
||||||
if (!polygon.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let prev = polygon[polygon.length - 1];
|
|
||||||
for (let index = 0; index < polygon.length; index += 1) {
|
|
||||||
const curr = polygon[index];
|
|
||||||
if (signedDistance(prev.x, prev.y, curr.x, curr.y, point.x, point.y) < -1e-6) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
prev = curr;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const polygonCentroid = (polygon) => {
|
|
||||||
let x = 0;
|
|
||||||
let y = 0;
|
|
||||||
polygon.forEach((point) => {
|
|
||||||
x += point.x;
|
|
||||||
y += point.y;
|
|
||||||
});
|
|
||||||
const count = polygon.length || 1;
|
|
||||||
return {
|
|
||||||
x: x / count,
|
|
||||||
y: y / count,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const recalcVisibleDocIds = useCallback(() => {
|
const recalcVisibleDocIds = useCallback(() => {
|
||||||
const layoutMap = layoutRef.current;
|
const layoutMap = layoutRef.current;
|
||||||
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
|
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
|
||||||
@@ -1089,7 +1071,6 @@ const recalcVisibleDocIds = useCallback(() => {
|
|||||||
canvasSize.height,
|
canvasSize.height,
|
||||||
ensureDocumentSize,
|
ensureDocumentSize,
|
||||||
documentLookup,
|
documentLookup,
|
||||||
docSizeVersion,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const syncLayoutSnapshot = useCallback(() => {
|
const syncLayoutSnapshot = useCallback(() => {
|
||||||
@@ -1100,7 +1081,8 @@ const syncLayoutSnapshot = useCallback(() => {
|
|||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return () => {};
|
if (!container) return () => {};
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
const nodeEnv = typeof globalThis !== 'undefined' ? globalThis.process?.env?.NODE_ENV : undefined;
|
||||||
|
if (nodeEnv !== 'production') {
|
||||||
console.log('[skeuo] canvas element', container);
|
console.log('[skeuo] canvas element', container);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1227,7 +1209,7 @@ const syncLayoutSnapshot = useCallback(() => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
recalcVisibleDocIds();
|
recalcVisibleDocIds();
|
||||||
}, [recalcVisibleDocIds, items.length, canvasSize.width, canvasSize.height]);
|
}, [recalcVisibleDocIds, items.length, canvasSize.width, canvasSize.height, docSizeVersion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (draggingId && !items.some((doc) => doc.id === draggingId)) {
|
if (draggingId && !items.some((doc) => doc.id === draggingId)) {
|
||||||
@@ -1571,20 +1553,18 @@ const syncLayoutSnapshot = useCallback(() => {
|
|||||||
if (event.dataTransfer) {
|
if (event.dataTransfer) {
|
||||||
event.dataTransfer.effectAllowed = 'copyMove';
|
event.dataTransfer.effectAllowed = 'copyMove';
|
||||||
}
|
}
|
||||||
} catch (
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-empty
|
console.warn('[skeuo] Failed to set drag effect', error);
|
||||||
error
|
}
|
||||||
) {}
|
|
||||||
|
|
||||||
const payload = JSON.stringify({ id: tag.id, label: tag.label, sourceDocId: doc.id });
|
const payload = JSON.stringify({ id: tag.id, label: tag.label, sourceDocId: doc.id });
|
||||||
try {
|
try {
|
||||||
event.dataTransfer?.setData('application/x-papercrate-tag', payload);
|
event.dataTransfer?.setData('application/x-papercrate-tag', payload);
|
||||||
event.dataTransfer?.setData('text/papercrate-tag', payload);
|
event.dataTransfer?.setData('text/papercrate-tag', payload);
|
||||||
event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
|
event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
|
||||||
} catch (
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-empty
|
console.warn('[skeuo] Failed to populate drag data for tag', error);
|
||||||
error
|
}
|
||||||
) {}
|
|
||||||
|
|
||||||
const pending = pendingDocTagDragRef.current;
|
const pending = pendingDocTagDragRef.current;
|
||||||
const node = event.currentTarget;
|
const node = event.currentTarget;
|
||||||
@@ -1613,10 +1593,9 @@ const syncLayoutSnapshot = useCallback(() => {
|
|||||||
if (preview && event.dataTransfer) {
|
if (preview && event.dataTransfer) {
|
||||||
try {
|
try {
|
||||||
event.dataTransfer.setDragImage(preview.clone, preview.offsetX, preview.offsetY);
|
event.dataTransfer.setDragImage(preview.clone, preview.offsetX, preview.offsetY);
|
||||||
} catch (
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-empty
|
console.warn('[skeuo] Failed to set drag image', error);
|
||||||
error
|
}
|
||||||
) {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
draggingTagRef.current = {
|
draggingTagRef.current = {
|
||||||
@@ -1869,16 +1848,12 @@ const DesktopWorkspaceView = () => {
|
|||||||
closeOverlay,
|
closeOverlay,
|
||||||
overlayOriginRect,
|
overlayOriginRect,
|
||||||
overlayOriginTransform,
|
overlayOriginTransform,
|
||||||
docSizeVersion,
|
|
||||||
} = useDesktopContext();
|
} = useDesktopContext();
|
||||||
|
|
||||||
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } =
|
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } =
|
||||||
useDocumentDrag();
|
useDocumentDrag();
|
||||||
|
|
||||||
const allSizesReady = useMemo(
|
const allSizesReady = items.every((doc) => ensureDocumentSize(doc));
|
||||||
() => items.every((doc) => ensureDocumentSize(doc)),
|
|
||||||
[items, ensureDocumentSize, docSizeVersion],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -2062,7 +2037,7 @@ export const createDesktopSurface = ({ workspaceProps, renderSidebarToggle }) =>
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button ghost"
|
className="icon-button"
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
aria-label="Refresh"
|
aria-label="Refresh"
|
||||||
title="Refresh"
|
title="Refresh"
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
<linearGradient id="a" gradientTransform="matrix(0.45451 0 0 0.455522 -1210.292114 616.172607)" gradientUnits="userSpaceOnUse" x1="2689.251953" x2="2918.069824" y1="-1106.802979" y2="-1106.802979">
|
<linearGradient id="folderGradient" gradientTransform="matrix(0.45451 0 0 0.455522 -1210.292114 616.172607)" gradientUnits="userSpaceOnUse" x1="2689.251953" x2="2918.069824" y1="-1106.802979" y2="-1106.802979">
|
||||||
<stop offset="0" stop-color="#62a0ea"/>
|
<stop offset="0" stop-color="var(--folder-icon-back, #62a0ea)"/>
|
||||||
<stop offset="0.0576991" stop-color="#afd4ff"/>
|
<stop offset="0.5" stop-color="var(--folder-icon-mid, #afd4ff)"/>
|
||||||
<stop offset="0.122204" stop-color="#62a0ea"/>
|
<stop offset="1" stop-color="var(--folder-icon-front, #62a0ea)"/>
|
||||||
<stop offset="0.873306" stop-color="#62a0ea"/>
|
|
||||||
<stop offset="0.955997" stop-color="#c0d5ea"/>
|
|
||||||
<stop offset="1" stop-color="#62a0ea"/>
|
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<path d="m 21.976562 12 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 86.03125 c 0 5.542969 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.457031 9.980469 -10 v -72.085938 c 0 -6.628906 -5.359375 -12 -11.972656 -12 h -46.027344 c -2.453125 0 -4.695312 -1.386718 -5.796875 -3.582031 l -1.503906 -2.992187 c -1.65625 -3.292969 -5.019531 -5.371094 -8.699219 -5.371094 z m 0 0" fill="#438de6"/>
|
<path d="m 21.976562 12 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 86.03125 c 0 5.542969 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.457031 9.980469 -10 v -72.085938 c 0 -6.628906 -5.359375 -12 -11.972656 -12 h -46.027344 c -2.453125 0 -4.695312 -1.386718 -5.796875 -3.582031 l -1.503906 -2.992187 c -1.65625 -3.292969 -5.019531 -5.371094 -8.699219 -5.371094 z m 0 0" fill="var(--folder-icon-back, #438de6)"/>
|
||||||
<path d="m 65.976562 36 c -2.746093 0 -5.226562 1.101562 -7.027343 2.890625 c -2.273438 2.253906 -5.382813 5.109375 -8.632813 5.109375 h -28.339844 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 54.03125 c 0 5.542969 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.457031 9.980469 -10 v -62.03125 c 0 -5.539062 -4.449219 -10 -9.980469 -10 z m 0 0" fill="url(#a)"/>
|
<path d="m 65.976562 36 c -2.746093 0 -5.226562 1.101562 -7.027343 2.890625 c -2.273438 2.253906 -5.382813 5.109375 -8.632813 5.109375 h -28.339844 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 54.03125 c 0 5.542969 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.457031 9.980469 -10 v -62.03125 c 0 -5.539062 -4.449219 -10 -9.980469 -10 z m 0 0" fill="url(#folderGradient)"/>
|
||||||
<path d="m 65.976562 32 c -2.746093 0 -5.226562 1.101562 -7.027343 2.890625 c -2.273438 2.253906 -5.382813 5.109375 -8.632813 5.109375 h -28.339844 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 55.976562 c 0 5.539063 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.460937 9.980469 -10 v -63.976562 c 0 -5.539062 -4.449219 -10 -9.980469 -10 z m 0 0" fill="#a4caee"/>
|
<path d="m 65.976562 32 c -2.746093 0 -5.226562 1.101562 -7.027343 2.890625 c -2.273438 2.253906 -5.382813 5.109375 -8.632813 5.109375 h -28.339844 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 55.976562 c 0 5.539063 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.460937 9.980469 -10 v -63.976562 c 0 -5.539062 -4.449219 -10 -9.980469 -10 z m 0 0" fill="var(--folder-icon-front, #a4caee)"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -38,10 +38,11 @@ function CorrespondentsPanel({
|
|||||||
try {
|
try {
|
||||||
await onUpdate(editingId, { name: trimmed });
|
await onUpdate(editingId, { name: trimmed });
|
||||||
cancelEdit();
|
cancelEdit();
|
||||||
} catch (
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-empty
|
onNotify?.('Failed to update correspondent.', 'error');
|
||||||
error
|
console.error('[correspondents] update failed', error);
|
||||||
) {}
|
setSaving(false);
|
||||||
|
}
|
||||||
}, [editingId, draftName, onUpdate, cancelEdit, onNotify]);
|
}, [editingId, draftName, onUpdate, cancelEdit, onNotify]);
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
@@ -53,16 +54,14 @@ function CorrespondentsPanel({
|
|||||||
if (editingId === correspondent.id) {
|
if (editingId === correspondent.id) {
|
||||||
cancelEdit();
|
cancelEdit();
|
||||||
}
|
}
|
||||||
} catch (
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-empty
|
onNotify?.('Failed to delete correspondent.', 'error');
|
||||||
error
|
console.error('[correspondents] delete failed', error);
|
||||||
) {
|
|
||||||
return;
|
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null);
|
setDeletingId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onDelete, editingId, cancelEdit],
|
[onDelete, editingId, cancelEdit, onNotify],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreate = useCallback(
|
const handleCreate = useCallback(
|
||||||
@@ -77,11 +76,9 @@ function CorrespondentsPanel({
|
|||||||
try {
|
try {
|
||||||
await onCreate({ name: trimmed });
|
await onCreate({ name: trimmed });
|
||||||
setCreateName('');
|
setCreateName('');
|
||||||
} catch (
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-empty
|
onNotify?.('Failed to create correspondent.', 'error');
|
||||||
error
|
console.error('[correspondents] create failed', error);
|
||||||
) {
|
|
||||||
return;
|
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,12 @@ export const preventAll = (event) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} catch (
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-empty
|
console.warn('[events] preventDefault failed', error);
|
||||||
error
|
}
|
||||||
) {}
|
|
||||||
try {
|
try {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
} catch (
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-empty
|
console.warn('[events] stopPropagation failed', error);
|
||||||
error
|
}
|
||||||
) {}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,16 +35,17 @@ const useDocumentDrag = () => {
|
|||||||
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
|
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
|
||||||
try {
|
try {
|
||||||
capturedTarget.releasePointerCapture(pointerId);
|
capturedTarget.releasePointerCapture(pointerId);
|
||||||
} catch (
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-empty
|
if (debugDrag) {
|
||||||
error
|
console.warn('[skeuo] releasePointerCapture failed', error);
|
||||||
) {}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
dragStateRef.current = null;
|
dragStateRef.current = null;
|
||||||
setDraggingId((current) => (current === state.docId ? null : current));
|
setDraggingId((current) => (current === state.docId ? null : current));
|
||||||
syncLayoutSnapshot();
|
syncLayoutSnapshot();
|
||||||
},
|
},
|
||||||
[setDraggingId, syncLayoutSnapshot],
|
[debugDrag, setDraggingId, syncLayoutSnapshot],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePointerDown = useCallback(
|
const handlePointerDown = useCallback(
|
||||||
@@ -83,10 +84,11 @@ const useDocumentDrag = () => {
|
|||||||
if (capturedTarget && typeof capturedTarget.setPointerCapture === 'function') {
|
if (capturedTarget && typeof capturedTarget.setPointerCapture === 'function') {
|
||||||
try {
|
try {
|
||||||
capturedTarget.setPointerCapture(event.pointerId);
|
capturedTarget.setPointerCapture(event.pointerId);
|
||||||
} catch (
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-empty
|
if (debugDrag) {
|
||||||
error
|
console.warn('[skeuo] setPointerCapture failed', error);
|
||||||
) {}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
dragStateRef.current = {
|
dragStateRef.current = {
|
||||||
docId,
|
docId,
|
||||||
@@ -196,6 +198,7 @@ const useDocumentDrag = () => {
|
|||||||
recalcVisibleDocIds();
|
recalcVisibleDocIds();
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
bringToFront,
|
||||||
canvasPadding,
|
canvasPadding,
|
||||||
canvasSize.height,
|
canvasSize.height,
|
||||||
canvasSize.width,
|
canvasSize.width,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import {
|
import {
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
@@ -15,6 +14,7 @@ import { formatFileSize } from '../utils/format';
|
|||||||
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||||
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
||||||
import PreviewZoomOverlay from './PreviewZoomOverlay';
|
import PreviewZoomOverlay from './PreviewZoomOverlay';
|
||||||
|
import { openOcrTextInNewTab } from '../utils/ocr';
|
||||||
|
|
||||||
const MAX_PREVIEW_STACK_ITEMS = 15;
|
const MAX_PREVIEW_STACK_ITEMS = 15;
|
||||||
|
|
||||||
@@ -199,13 +199,8 @@ const PreviewStack = ({
|
|||||||
onItemActivate,
|
onItemActivate,
|
||||||
onOpenPreview,
|
onOpenPreview,
|
||||||
onZoomPreview,
|
onZoomPreview,
|
||||||
activeItemId = null,
|
|
||||||
}) => {
|
}) => {
|
||||||
if (!items.length) {
|
const limited = useMemo(() => items.slice(0, maxItems), [items, maxItems]);
|
||||||
return <span className="meta">{emptyMessage}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const limited = items.slice(0, maxItems);
|
|
||||||
const hasMultiple = limited.length > 1;
|
const hasMultiple = limited.length > 1;
|
||||||
const preparedItems = useMemo(
|
const preparedItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -216,6 +211,10 @@ const PreviewStack = ({
|
|||||||
[limited],
|
[limited],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!limited.length) {
|
||||||
|
return <span className="meta">{emptyMessage}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="preview-stack preview-stack--stacked">
|
<div className="preview-stack preview-stack--stacked">
|
||||||
{preparedItems.map(({ entry, angle }, index) => {
|
{preparedItems.map(({ entry, angle }, index) => {
|
||||||
@@ -288,7 +287,6 @@ const DetailPanel = ({
|
|||||||
onTagAdd,
|
onTagAdd,
|
||||||
onTagRemove,
|
onTagRemove,
|
||||||
onRegenerateThumbnails,
|
onRegenerateThumbnails,
|
||||||
previewEntry,
|
|
||||||
onOpenPreview,
|
onOpenPreview,
|
||||||
onBulkTagAdd,
|
onBulkTagAdd,
|
||||||
onBulkTagRemove,
|
onBulkTagRemove,
|
||||||
@@ -296,10 +294,10 @@ const DetailPanel = ({
|
|||||||
onBulkCorrespondentAdd,
|
onBulkCorrespondentAdd,
|
||||||
onBulkCorrespondentRemove,
|
onBulkCorrespondentRemove,
|
||||||
onPromoteSelection,
|
onPromoteSelection,
|
||||||
activePreviewId = null,
|
|
||||||
onUpdateTitle = async () => false,
|
onUpdateTitle = async () => false,
|
||||||
ensureAssetUrl = null,
|
ensureAssetUrl = null,
|
||||||
getDocumentAsset = () => null,
|
getDocumentAsset = () => null,
|
||||||
|
ensurePreviewData = () => Promise.resolve(),
|
||||||
correspondents = [],
|
correspondents = [],
|
||||||
onCorrespondentAdd,
|
onCorrespondentAdd,
|
||||||
onCorrespondentRemove,
|
onCorrespondentRemove,
|
||||||
@@ -329,22 +327,19 @@ const DetailPanel = ({
|
|||||||
const [titleDraft, setTitleDraft] = useState('');
|
const [titleDraft, setTitleDraft] = useState('');
|
||||||
const [titleSaving, setTitleSaving] = useState(false);
|
const [titleSaving, setTitleSaving] = useState(false);
|
||||||
const [titleError, setTitleError] = useState(null);
|
const [titleError, setTitleError] = useState(null);
|
||||||
const [ocrOpen, setOcrOpen] = useState(false);
|
|
||||||
const [ocrUrl, setOcrUrl] = useState(null);
|
|
||||||
const [ocrLoading, setOcrLoading] = useState(false);
|
|
||||||
const [ocrError, setOcrError] = useState(null);
|
|
||||||
const [zoomedPreview, setZoomedPreview] = useState(null);
|
const [zoomedPreview, setZoomedPreview] = useState(null);
|
||||||
|
|
||||||
|
const bulkDocumentIds = useMemo(
|
||||||
|
() => selectedDocuments.map((doc) => doc?.id).filter(Boolean),
|
||||||
|
[selectedDocuments],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!singleDoc) {
|
if (!singleDoc) {
|
||||||
setTitleEditDocId(null);
|
setTitleEditDocId(null);
|
||||||
setTitleDraft('');
|
setTitleDraft('');
|
||||||
setTitleError(null);
|
setTitleError(null);
|
||||||
setTitleSaving(false);
|
setTitleSaving(false);
|
||||||
setOcrOpen(false);
|
|
||||||
setOcrLoading(false);
|
|
||||||
setOcrError(null);
|
|
||||||
setOcrUrl(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,13 +351,6 @@ const DetailPanel = ({
|
|||||||
}
|
}
|
||||||
}, [singleDoc, titleEditDocId]);
|
}, [singleDoc, titleEditDocId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOcrOpen(false);
|
|
||||||
setOcrLoading(false);
|
|
||||||
setOcrError(null);
|
|
||||||
setOcrUrl(null);
|
|
||||||
}, [singleDoc?.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setZoomedPreview(null);
|
setZoomedPreview(null);
|
||||||
}, [selectionKey]);
|
}, [selectionKey]);
|
||||||
@@ -420,59 +408,21 @@ const DetailPanel = ({
|
|||||||
[singleDoc, getDocumentAsset],
|
[singleDoc, getDocumentAsset],
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadOcrUrl = useCallback(async () => {
|
const openOcr = useCallback(async () => {
|
||||||
if (!singleDoc) {
|
if (!singleDoc) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const asset = getDocumentAsset(singleDoc, 'ocr-text');
|
|
||||||
if (!asset) {
|
|
||||||
setOcrError('No OCR text available for this document.');
|
|
||||||
setOcrUrl(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOcrLoading(true);
|
|
||||||
setOcrError(null);
|
|
||||||
try {
|
try {
|
||||||
let entry = asset;
|
await openOcrTextInNewTab({
|
||||||
let url = entry?.url ||
|
document: singleDoc,
|
||||||
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
|
ensurePreviewData,
|
||||||
ensureAssetUrl,
|
getDocumentAsset,
|
||||||
getAsset: getDocumentAsset,
|
ensureAssetUrl,
|
||||||
});
|
});
|
||||||
if (!url && typeof ensureAssetUrl === 'function') {
|
|
||||||
const ensured = await ensureAssetUrl(singleDoc.id, asset, { force: false });
|
|
||||||
if (ensured) {
|
|
||||||
entry = ensured;
|
|
||||||
}
|
|
||||||
url = entry?.url ||
|
|
||||||
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
|
|
||||||
ensureAssetUrl,
|
|
||||||
getAsset: getDocumentAsset,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!url) {
|
|
||||||
throw new Error('OCR text URL is unavailable.');
|
|
||||||
}
|
|
||||||
setOcrUrl(url);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setOcrError(error.message || 'Failed to load OCR text.');
|
/* noop */
|
||||||
setOcrUrl(null);
|
|
||||||
} finally {
|
|
||||||
setOcrLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [singleDoc, ensureAssetUrl, getDocumentAsset]);
|
}, [singleDoc, ensurePreviewData, getDocumentAsset, ensureAssetUrl]);
|
||||||
|
|
||||||
const openOcrModal = useCallback(() => {
|
|
||||||
if (!singleDoc) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOcrOpen(true);
|
|
||||||
loadOcrUrl();
|
|
||||||
}, [singleDoc, loadOcrUrl]);
|
|
||||||
|
|
||||||
const closeOcrModal = useCallback(() => {
|
|
||||||
setOcrOpen(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const singlePreviewNavigator = useAssetNavigator({
|
const singlePreviewNavigator = useAssetNavigator({
|
||||||
document: singleDoc,
|
document: singleDoc,
|
||||||
@@ -594,9 +544,6 @@ const DetailPanel = ({
|
|||||||
const topCardinality = stackPreviewNavigator.cardinality;
|
const topCardinality = stackPreviewNavigator.cardinality;
|
||||||
const topEffectiveCardinality = topCardinality || (stackPreviewNavigator.currentUrl ? 1 : 0);
|
const topEffectiveCardinality = topCardinality || (stackPreviewNavigator.currentUrl ? 1 : 0);
|
||||||
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
|
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
|
||||||
const topOrdinal = stackPreviewNavigator.ordinal;
|
|
||||||
const topCanGoPrev = stackPreviewNavigator.canGoPrev;
|
|
||||||
const topCanGoNext = stackPreviewNavigator.canGoNext;
|
|
||||||
|
|
||||||
const bulkTagUnion = useMemo(() => {
|
const bulkTagUnion = useMemo(() => {
|
||||||
if (!selectedDocuments.length) return [];
|
if (!selectedDocuments.length) return [];
|
||||||
@@ -835,6 +782,24 @@ const DetailPanel = ({
|
|||||||
}
|
}
|
||||||
}, [zoomedPreview, zoomDisplay]);
|
}, [zoomedPreview, zoomDisplay]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
documentId: singleNavigatorDocId,
|
||||||
|
asset: singleNavigatorAsset,
|
||||||
|
ordinal: singleNavigatorOrdinal,
|
||||||
|
canGoPrev: singleNavigatorCanGoPrev,
|
||||||
|
canGoNext: singleNavigatorCanGoNext,
|
||||||
|
cardinality: singleNavigatorCardinality,
|
||||||
|
} = singlePreviewNavigator;
|
||||||
|
|
||||||
|
const {
|
||||||
|
documentId: stackNavigatorDocId,
|
||||||
|
asset: stackNavigatorAsset,
|
||||||
|
ordinal: stackNavigatorOrdinal,
|
||||||
|
canGoPrev: stackNavigatorCanGoPrev,
|
||||||
|
canGoNext: stackNavigatorCanGoNext,
|
||||||
|
cardinality: stackNavigatorCardinality,
|
||||||
|
} = stackPreviewNavigator;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof ensureAssetUrl !== 'function') {
|
if (typeof ensureAssetUrl !== 'function') {
|
||||||
return;
|
return;
|
||||||
@@ -882,20 +847,40 @@ const DetailPanel = ({
|
|||||||
requests.forEach((promise) => promise?.catch?.(() => {}));
|
requests.forEach((promise) => promise?.catch?.(() => {}));
|
||||||
};
|
};
|
||||||
|
|
||||||
warmNavigator(singlePreviewNavigator);
|
const singleWarmState = {
|
||||||
warmNavigator(stackPreviewNavigator);
|
documentId: singleNavigatorDocId,
|
||||||
|
asset: singleNavigatorAsset,
|
||||||
|
ordinal: singleNavigatorOrdinal,
|
||||||
|
canGoPrev: singleNavigatorCanGoPrev,
|
||||||
|
canGoNext: singleNavigatorCanGoNext,
|
||||||
|
cardinality: singleNavigatorCardinality,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stackWarmState = {
|
||||||
|
documentId: stackNavigatorDocId,
|
||||||
|
asset: stackNavigatorAsset,
|
||||||
|
ordinal: stackNavigatorOrdinal,
|
||||||
|
canGoPrev: stackNavigatorCanGoPrev,
|
||||||
|
canGoNext: stackNavigatorCanGoNext,
|
||||||
|
cardinality: stackNavigatorCardinality,
|
||||||
|
};
|
||||||
|
|
||||||
|
warmNavigator(singleWarmState);
|
||||||
|
warmNavigator(stackWarmState);
|
||||||
}, [
|
}, [
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
singlePreviewNavigator.documentId,
|
singleNavigatorDocId,
|
||||||
singlePreviewNavigator.asset,
|
singleNavigatorAsset,
|
||||||
singlePreviewNavigator.ordinal,
|
singleNavigatorOrdinal,
|
||||||
singlePreviewNavigator.canGoPrev,
|
singleNavigatorCanGoPrev,
|
||||||
singlePreviewNavigator.canGoNext,
|
singleNavigatorCanGoNext,
|
||||||
stackPreviewNavigator.documentId,
|
singleNavigatorCardinality,
|
||||||
stackPreviewNavigator.asset,
|
stackNavigatorDocId,
|
||||||
stackPreviewNavigator.ordinal,
|
stackNavigatorAsset,
|
||||||
stackPreviewNavigator.canGoPrev,
|
stackNavigatorOrdinal,
|
||||||
stackPreviewNavigator.canGoNext,
|
stackNavigatorCanGoPrev,
|
||||||
|
stackNavigatorCanGoNext,
|
||||||
|
stackNavigatorCardinality,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const renderSingle = () => {
|
const renderSingle = () => {
|
||||||
@@ -940,7 +925,6 @@ const DetailPanel = ({
|
|||||||
onItemActivate={handlePreviewActivate}
|
onItemActivate={handlePreviewActivate}
|
||||||
onOpenPreview={onOpenPreview}
|
onOpenPreview={onOpenPreview}
|
||||||
onZoomPreview={handleSingleZoom}
|
onZoomPreview={handleSingleZoom}
|
||||||
activeItemId={activePreviewId}
|
|
||||||
/>
|
/>
|
||||||
{hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? (
|
{hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? (
|
||||||
<div className="preview-pane__nav preview-pane__nav--overlay">
|
<div className="preview-pane__nav preview-pane__nav--overlay">
|
||||||
@@ -1018,7 +1002,7 @@ const DetailPanel = ({
|
|||||||
<h3 style={{ margin: 0 }}>{displayName}</h3>
|
<h3 style={{ margin: 0 }}>{displayName}</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button ghost"
|
className="icon-button"
|
||||||
onClick={startTitleEdit}
|
onClick={startTitleEdit}
|
||||||
aria-label="Edit title"
|
aria-label="Edit title"
|
||||||
title="Edit title"
|
title="Edit title"
|
||||||
@@ -1138,47 +1122,6 @@ const DetailPanel = ({
|
|||||||
<pre className="detail-metadata__block">{JSON.stringify(metadata, null, 2)}</pre>
|
<pre className="detail-metadata__block">{JSON.stringify(metadata, null, 2)}</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasOcrAsset && ocrOpen
|
|
||||||
? createPortal(
|
|
||||||
<div className="modal-backdrop" role="presentation" onClick={closeOcrModal}>
|
|
||||||
<div
|
|
||||||
className="modal modal--ocr"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="OCR text"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="modal__header">
|
|
||||||
<h3>OCR Text</h3>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button ghost"
|
|
||||||
onClick={closeOcrModal}
|
|
||||||
aria-label="Close OCR text"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="modal__body detail-ocr__content">
|
|
||||||
{ocrLoading ? (
|
|
||||||
<div className="meta">Loading OCR text…</div>
|
|
||||||
) : ocrError ? (
|
|
||||||
<div className="status-inline error">{ocrError}</div>
|
|
||||||
) : ocrUrl ? (
|
|
||||||
<iframe
|
|
||||||
title="OCR text"
|
|
||||||
src={ocrUrl}
|
|
||||||
className="detail-ocr__frame"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="meta">OCR text empty.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
)
|
|
||||||
: null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1186,9 +1129,7 @@ const DetailPanel = ({
|
|||||||
const renderBulk = () => {
|
const renderBulk = () => {
|
||||||
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
||||||
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
|
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
|
||||||
const topDoc = stackTopDocument;
|
|
||||||
const topDocIdLocal = topDocId;
|
const topDocIdLocal = topDocId;
|
||||||
const topOrdinal = stackPreviewNavigator.ordinal;
|
|
||||||
const topCardinalityLocal = topEffectiveCardinality;
|
const topCardinalityLocal = topEffectiveCardinality;
|
||||||
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
|
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
|
||||||
const topCanGoPrev = stackPreviewNavigator.canGoPrev;
|
const topCanGoPrev = stackPreviewNavigator.canGoPrev;
|
||||||
@@ -1197,6 +1138,7 @@ const DetailPanel = ({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
const documentIds = bulkDocumentIds;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1207,7 +1149,6 @@ const DetailPanel = ({
|
|||||||
onItemActivate={handlePreviewActivate}
|
onItemActivate={handlePreviewActivate}
|
||||||
onOpenPreview={onOpenPreview}
|
onOpenPreview={onOpenPreview}
|
||||||
onZoomPreview={handleStackZoom}
|
onZoomPreview={handleStackZoom}
|
||||||
activeItemId={activePreviewId}
|
|
||||||
/>
|
/>
|
||||||
{topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? (
|
{topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? (
|
||||||
<div className="preview-pane__nav preview-pane__nav--overlay">
|
<div className="preview-pane__nav preview-pane__nav--overlay">
|
||||||
@@ -1258,8 +1199,10 @@ const DetailPanel = ({
|
|||||||
title="Tags"
|
title="Tags"
|
||||||
tags={bulkTagUnion}
|
tags={bulkTagUnion}
|
||||||
emptyMessage="No tags assigned."
|
emptyMessage="No tags assigned."
|
||||||
onRemove={(tag) => onBulkTagRemove?.({ label: tag.label })}
|
onRemove={(tag) => onBulkTagRemove?.({ label: tag.label, documentIds })}
|
||||||
onAdd={({ value, input }) => onBulkTagAdd?.({ label: value, input })}
|
onAdd={({ value, input }) =>
|
||||||
|
onBulkTagAdd?.({ label: value, input, documentIds })
|
||||||
|
}
|
||||||
addPlaceholder="Add tag to selection"
|
addPlaceholder="Add tag to selection"
|
||||||
addButtonLabel="Add tag"
|
addButtonLabel="Add tag"
|
||||||
datalistId="tag-catalog-bulk"
|
datalistId="tag-catalog-bulk"
|
||||||
@@ -1271,7 +1214,7 @@ const DetailPanel = ({
|
|||||||
entries={bulkCorrespondents}
|
entries={bulkCorrespondents}
|
||||||
onRemove={handleBulkCorrespondentRemove}
|
onRemove={handleBulkCorrespondentRemove}
|
||||||
onAdd={({ name, input }) =>
|
onAdd={({ name, input }) =>
|
||||||
onBulkCorrespondentAdd?.({ name, input })
|
onBulkCorrespondentAdd?.({ name, input, documentIds })
|
||||||
}
|
}
|
||||||
addPlaceholder="Add correspondent to selection"
|
addPlaceholder="Add correspondent to selection"
|
||||||
datalistId="correspondent-catalog-bulk"
|
datalistId="correspondent-catalog-bulk"
|
||||||
@@ -1293,7 +1236,7 @@ const DetailPanel = ({
|
|||||||
<div className="panel-actions">
|
<div className="panel-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button ghost"
|
className="icon-button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close detail panel"
|
aria-label="Close detail panel"
|
||||||
title="Close detail panel"
|
title="Close detail panel"
|
||||||
@@ -1303,7 +1246,7 @@ const DetailPanel = ({
|
|||||||
{singleDoc ? (
|
{singleDoc ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button ghost"
|
className="icon-button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onOpenPreview(singleDoc.id);
|
onOpenPreview(singleDoc.id);
|
||||||
@@ -1319,10 +1262,10 @@ const DetailPanel = ({
|
|||||||
{isBulkSelection && onBulkReanalyze ? (
|
{isBulkSelection && onBulkReanalyze ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button ghost"
|
className="icon-button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onBulkReanalyze();
|
onBulkReanalyze(bulkDocumentIds);
|
||||||
}}
|
}}
|
||||||
aria-label="Re-run analysis for selection"
|
aria-label="Re-run analysis for selection"
|
||||||
title="Re-run analysis for selection"
|
title="Re-run analysis for selection"
|
||||||
@@ -1346,10 +1289,10 @@ const DetailPanel = ({
|
|||||||
{showOcrAction ? (
|
{showOcrAction ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button ghost"
|
className="icon-button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
openOcrModal();
|
openOcr();
|
||||||
}}
|
}}
|
||||||
aria-label="View OCR text"
|
aria-label="View OCR text"
|
||||||
title="View OCR text"
|
title="View OCR text"
|
||||||
@@ -1360,7 +1303,7 @@ const DetailPanel = ({
|
|||||||
{singleDoc ? (
|
{singleDoc ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button ghost"
|
className="icon-button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onRegenerateThumbnails(singleDoc.id);
|
onRegenerateThumbnails(singleDoc.id);
|
||||||
|
|||||||
@@ -10,14 +10,12 @@ import {
|
|||||||
FolderIcon,
|
FolderIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
RefreshIcon,
|
RefreshIcon,
|
||||||
FolderPlusIcon,
|
|
||||||
MinusVerticalIcon,
|
MinusVerticalIcon,
|
||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
} from '../ui/icons';
|
} from '../ui/icons';
|
||||||
|
|
||||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||||
const DEFAULT_GRID_ICON_SIZE = 144;
|
const DEFAULT_GRID_ICON_SIZE = 144;
|
||||||
const DEFAULT_GRID_TITLE_SIZE = '11px';
|
|
||||||
const LIST_ICON_SIZE = 48;
|
const LIST_ICON_SIZE = 48;
|
||||||
|
|
||||||
const getPageCount = (doc) =>
|
const getPageCount = (doc) =>
|
||||||
@@ -173,7 +171,7 @@ const DocumentThumbnailImage = ({
|
|||||||
} else {
|
} else {
|
||||||
delete node.dataset.thumbnailAspect;
|
delete node.dataset.thumbnailAspect;
|
||||||
}
|
}
|
||||||
}, [aspectRatio]);
|
}, [aspectRatio, visibilityRef]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="document-thumbnail-wrapper" ref={visibilityRef}>
|
<div className="document-thumbnail-wrapper" ref={visibilityRef}>
|
||||||
@@ -198,15 +196,11 @@ const DocumentThumbnailImage = ({
|
|||||||
|
|
||||||
const DocumentsTable = ({
|
const DocumentsTable = ({
|
||||||
currentFolderName,
|
currentFolderName,
|
||||||
breadcrumbs,
|
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onShowSkeuoWorkspace = () => {},
|
onShowSkeuoWorkspace = () => {},
|
||||||
onRequestCreateFolder,
|
|
||||||
creatingFolder = false,
|
|
||||||
subfolders,
|
subfolders,
|
||||||
documents,
|
documents,
|
||||||
searchResults,
|
searchResults,
|
||||||
isFilterActive,
|
|
||||||
onFolderSelect,
|
onFolderSelect,
|
||||||
onFolderDrop,
|
onFolderDrop,
|
||||||
onFolderDragOver,
|
onFolderDragOver,
|
||||||
@@ -220,7 +214,6 @@ const DocumentsTable = ({
|
|||||||
onDocumentRowClick,
|
onDocumentRowClick,
|
||||||
onDocumentOpen,
|
onDocumentOpen,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
focusedDocumentId,
|
|
||||||
focusedRowKey,
|
focusedRowKey,
|
||||||
draggingDocumentIds = [],
|
draggingDocumentIds = [],
|
||||||
onDocumentDragStart,
|
onDocumentDragStart,
|
||||||
@@ -396,10 +389,9 @@ const DocumentsTable = ({
|
|||||||
if (parsed?.id && onDocumentTagDrop) {
|
if (parsed?.id && onDocumentTagDrop) {
|
||||||
onDocumentTagDrop(documentId, parsed);
|
onDocumentTagDrop(documentId, parsed);
|
||||||
}
|
}
|
||||||
} catch (
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-empty
|
console.warn('[documents] Failed to parse tag drop payload', error);
|
||||||
error
|
}
|
||||||
) {}
|
|
||||||
},
|
},
|
||||||
[isTagDragEvent, onDocumentTagDrop],
|
[isTagDragEvent, onDocumentTagDrop],
|
||||||
);
|
);
|
||||||
@@ -517,13 +509,6 @@ const DocumentsTable = ({
|
|||||||
<ViewGridIcon className="view-toggle__icon" size={18} />
|
<ViewGridIcon className="view-toggle__icon" size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onRequestCreateFolder}
|
|
||||||
disabled={creatingFolder}
|
|
||||||
>
|
|
||||||
{creatingFolder ? 'Creating…' : 'New folder'}
|
|
||||||
</button>
|
|
||||||
<button className="secondary" onClick={onRefresh}>
|
<button className="secondary" onClick={onRefresh}>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
@@ -718,10 +703,9 @@ const DocumentsTable = ({
|
|||||||
event.dataTransfer?.setData('application/x-papercrate-tag', payload);
|
event.dataTransfer?.setData('application/x-papercrate-tag', payload);
|
||||||
event.dataTransfer?.setData('text/papercrate-tag', payload);
|
event.dataTransfer?.setData('text/papercrate-tag', payload);
|
||||||
event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
|
event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
|
||||||
} catch (
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-empty
|
console.warn('[documents] Failed to configure tag drag payload', error);
|
||||||
error
|
}
|
||||||
) {}
|
|
||||||
}}
|
}}
|
||||||
onDragEnd={(event) => {
|
onDragEnd={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -826,7 +810,7 @@ const DocumentsTable = ({
|
|||||||
{folder.id !== 'root' && onFolderRename && (
|
{folder.id !== 'root' && onFolderRename && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button ghost"
|
className="icon-button"
|
||||||
title="Rename"
|
title="Rename"
|
||||||
aria-label={`Rename folder ${folder.name}`}
|
aria-label={`Rename folder ${folder.name}`}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
@@ -929,21 +913,20 @@ const DocumentsTable = ({
|
|||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
try {
|
try {
|
||||||
if (event.dataTransfer) {
|
if (event.dataTransfer) {
|
||||||
event.dataTransfer.effectAllowed = 'copyMove';
|
event.dataTransfer.effectAllowed = 'copyMove';
|
||||||
}
|
}
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
id: tag.id,
|
id: tag.id,
|
||||||
label: tag.label,
|
label: tag.label,
|
||||||
sourceDocId: doc.id,
|
sourceDocId: doc.id,
|
||||||
});
|
});
|
||||||
event.dataTransfer?.setData('application/x-papercrate-tag', payload);
|
event.dataTransfer?.setData('application/x-papercrate-tag', payload);
|
||||||
event.dataTransfer?.setData('text/papercrate-tag', payload);
|
event.dataTransfer?.setData('text/papercrate-tag', payload);
|
||||||
event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
|
event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
|
||||||
} catch (
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-empty
|
console.warn('[documents] Failed to configure tag drag payload', error);
|
||||||
error
|
}
|
||||||
) {}
|
|
||||||
}}
|
}}
|
||||||
onDragEnd={(event) => {
|
onDragEnd={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -984,7 +967,7 @@ const DocumentsTable = ({
|
|||||||
{onDocumentRename && (
|
{onDocumentRename && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button ghost"
|
className="icon-button"
|
||||||
title="Rename"
|
title="Rename"
|
||||||
aria-label={`Rename document ${doc.title || doc.original_name}`}
|
aria-label={`Rename document ${doc.title || doc.original_name}`}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
@@ -1063,8 +1046,6 @@ export { DocumentThumbnailImage };
|
|||||||
export const createDocumentsTableHeaderActions = ({
|
export const createDocumentsTableHeaderActions = ({
|
||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
onRequestCreateFolder,
|
|
||||||
creatingFolder,
|
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onShowSkeuoWorkspace,
|
onShowSkeuoWorkspace,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -1097,17 +1078,7 @@ export const createDocumentsTableHeaderActions = ({
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button ghost"
|
className="icon-button"
|
||||||
onClick={onRequestCreateFolder}
|
|
||||||
disabled={creatingFolder}
|
|
||||||
aria-label={creatingFolder ? 'Creating folder…' : 'Create folder'}
|
|
||||||
title={creatingFolder ? 'Creating folder…' : 'Create folder'}
|
|
||||||
>
|
|
||||||
<FolderPlusIcon />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button ghost"
|
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
aria-label="Refresh"
|
aria-label="Refresh"
|
||||||
title="Refresh"
|
title="Refresh"
|
||||||
@@ -1127,14 +1098,13 @@ export const createDocumentsSurface = ({
|
|||||||
onNavigateParent,
|
onNavigateParent,
|
||||||
renderSidebarToggle,
|
renderSidebarToggle,
|
||||||
detailProps,
|
detailProps,
|
||||||
|
detailOpen = false,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
currentFolderName,
|
currentFolderName,
|
||||||
searchResults,
|
searchResults,
|
||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
onRequestCreateFolder,
|
|
||||||
creatingFolder,
|
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onShowSkeuoWorkspace,
|
onShowSkeuoWorkspace,
|
||||||
} = tableProps;
|
} = tableProps;
|
||||||
@@ -1147,8 +1117,6 @@ export const createDocumentsSurface = ({
|
|||||||
const actions = createDocumentsTableHeaderActions({
|
const actions = createDocumentsTableHeaderActions({
|
||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
onRequestCreateFolder,
|
|
||||||
creatingFolder,
|
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onShowSkeuoWorkspace,
|
onShowSkeuoWorkspace,
|
||||||
});
|
});
|
||||||
@@ -1158,7 +1126,7 @@ export const createDocumentsSurface = ({
|
|||||||
? (
|
? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button ghost"
|
className="icon-button"
|
||||||
onClick={onNavigateParent}
|
onClick={onNavigateParent}
|
||||||
aria-label="Go to parent folder"
|
aria-label="Go to parent folder"
|
||||||
title="Go to parent folder"
|
title="Go to parent folder"
|
||||||
@@ -1176,16 +1144,7 @@ export const createDocumentsSurface = ({
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const detail = (() => {
|
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
|
||||||
if (!detailProps) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const count = detailProps.selectedDocuments?.length || 0;
|
|
||||||
if (!count) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return <DetailPanel {...detailProps} />;
|
|
||||||
})();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: 'documents',
|
key: 'documents',
|
||||||
|
|||||||
+1032
-994
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,123 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const StatusBanner = ({ status }) => {
|
||||||
|
if (!status) return null;
|
||||||
|
return <div className={`status-banner ${status.variant}`}>{status.message}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoginView = ({
|
||||||
|
status,
|
||||||
|
tenantSelection,
|
||||||
|
onSelectTenant,
|
||||||
|
onCancelSelection,
|
||||||
|
selectingTenantId,
|
||||||
|
onPasskeyLogin,
|
||||||
|
onSignup,
|
||||||
|
passkeySupported = false,
|
||||||
|
passkeyLoading = false,
|
||||||
|
signupSupported = false,
|
||||||
|
signupLoading = false,
|
||||||
|
}) => {
|
||||||
|
const hasTenantSelection = Boolean(tenantSelection?.tenants?.length);
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
|
||||||
|
const handlePasskeyClick = () => {
|
||||||
|
if (!onPasskeyLogin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPasskeyLogin(username);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignupClick = () => {
|
||||||
|
if (!onSignup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSignup(username);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!passkeySupported || passkeyLoading || !username.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlePasskeyClick();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-screen">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1>Papercrate</h1>
|
||||||
|
{hasTenantSelection ? (
|
||||||
|
<div className="login-card__selection">
|
||||||
|
<p>Select a tenant to finish signing in.</p>
|
||||||
|
<div className="login-card__tenant-list">
|
||||||
|
{tenantSelection.tenants.map((tenant) => (
|
||||||
|
<button
|
||||||
|
key={tenant.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectTenant?.(tenant)}
|
||||||
|
disabled={Boolean(selectingTenantId)}
|
||||||
|
className={
|
||||||
|
selectingTenantId === tenant.id
|
||||||
|
? 'login-card__tenant-button is-loading'
|
||||||
|
: 'login-card__tenant-button'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tenant.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="login-card__back-button"
|
||||||
|
onClick={() => onCancelSelection?.()}
|
||||||
|
disabled={Boolean(selectingTenantId)}
|
||||||
|
>
|
||||||
|
Use a different account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p>Use your registered passkey to sign in or create a new account.</p>
|
||||||
|
<form className="login-card__fields" onSubmit={handleSubmit}>
|
||||||
|
<label htmlFor="username">Username</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
placeholder="Username"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{passkeySupported ? (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="login-card__passkey-button"
|
||||||
|
disabled={passkeyLoading || !username.trim()}
|
||||||
|
>
|
||||||
|
{passkeyLoading ? 'Signing in…' : 'Sign in with passkey'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<p className="settings-empty">Passkeys are not supported in this browser.</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
{signupSupported ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="login-card__signup-button"
|
||||||
|
onClick={handleSignupClick}
|
||||||
|
disabled={signupLoading || !username.trim()}
|
||||||
|
>
|
||||||
|
{signupLoading ? 'Creating account…' : 'Create account with passkey'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<StatusBanner status={status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginView;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React from 'react';
|
||||||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
|
||||||
import { formatFileSize } from '../utils/format';
|
import { formatFileSize } from '../utils/format';
|
||||||
import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
|
import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
|
||||||
|
import { openOcrTextInNewTab } from '../utils/ocr';
|
||||||
|
|
||||||
const PreviewWorkspace = ({
|
const PreviewWorkspace = ({
|
||||||
document,
|
document,
|
||||||
@@ -24,7 +24,7 @@ const PreviewWorkspace = ({
|
|||||||
const tags = Array.isArray(document.tags) ? document.tags : [];
|
const tags = Array.isArray(document.tags) ? document.tags : [];
|
||||||
const correspondents = Array.isArray(document.correspondents) ? document.correspondents : [];
|
const correspondents = Array.isArray(document.correspondents) ? document.correspondents : [];
|
||||||
|
|
||||||
const metadataSummary = useMemo(() => {
|
const metadataSummary = (() => {
|
||||||
const rows = [];
|
const rows = [];
|
||||||
if (mime) rows.push(['Type', mime]);
|
if (mime) rows.push(['Type', mime]);
|
||||||
if (sizeLabel) rows.push(['Size', sizeLabel]);
|
if (sizeLabel) rows.push(['Size', sizeLabel]);
|
||||||
@@ -45,7 +45,7 @@ const PreviewWorkspace = ({
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}, [mime, sizeLabel, issuedAt, createdAt, updatedAt, folderName, tags, correspondents]);
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="preview-workspace">
|
<section className="preview-workspace">
|
||||||
@@ -106,21 +106,15 @@ export const createPreviewWorkspaceHeaderActions = ({
|
|||||||
const hasOcr = Boolean(ocrAsset);
|
const hasOcr = Boolean(ocrAsset);
|
||||||
|
|
||||||
const handleOcrClick = async () => {
|
const handleOcrClick = async () => {
|
||||||
if (!ocrAsset) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const ensured = await ensureAssetUrl(document.id, ocrAsset, { force: false });
|
const success = await openOcrTextInNewTab({
|
||||||
const entry = ensured || ocrAsset;
|
document,
|
||||||
const url = entry?.url
|
getDocumentAsset,
|
||||||
|| resolveDocumentAssetUrl(document, 'ocr-text', {
|
ensureAssetUrl,
|
||||||
ensureAssetUrl,
|
});
|
||||||
getAsset: getDocumentAsset,
|
if (!success) {
|
||||||
});
|
|
||||||
if (!url) {
|
|
||||||
throw new Error('OCR text URL unavailable.');
|
throw new Error('OCR text URL unavailable.');
|
||||||
}
|
}
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifyApiError(error, 'Unable to open OCR text.');
|
notifyApiError(error, 'Unable to open OCR text.');
|
||||||
}
|
}
|
||||||
@@ -143,7 +137,7 @@ export const createPreviewWorkspaceHeaderActions = ({
|
|||||||
{hasOcr ? (
|
{hasOcr ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button ghost"
|
className="icon-button"
|
||||||
onClick={handleOcrClick}
|
onClick={handleOcrClick}
|
||||||
aria-label="View OCR text"
|
aria-label="View OCR text"
|
||||||
title="View OCR text"
|
title="View OCR text"
|
||||||
@@ -153,7 +147,7 @@ export const createPreviewWorkspaceHeaderActions = ({
|
|||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button ghost"
|
className="icon-button"
|
||||||
onClick={() => onRegenerate(document.id)}
|
onClick={() => onRegenerate(document.id)}
|
||||||
aria-label="Re-run analysis"
|
aria-label="Re-run analysis"
|
||||||
title="Re-run analysis"
|
title="Re-run analysis"
|
||||||
@@ -185,7 +179,7 @@ export const createPreviewSurface = ({
|
|||||||
? (
|
? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button ghost"
|
className="icon-button"
|
||||||
onClick={() => onClose?.()}
|
onClick={() => onClose?.()}
|
||||||
aria-label="Close preview"
|
aria-label="Close preview"
|
||||||
title="Close preview"
|
title="Close preview"
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import React, { useMemo, useState, useCallback, useEffect } from 'react';
|
import React, { useMemo, useState, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
id: 'passkeys',
|
||||||
|
label: 'Passkeys',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'webdav',
|
id: 'webdav',
|
||||||
label: 'WebDAV',
|
label: 'WebDAV',
|
||||||
@@ -19,12 +23,21 @@ const SettingsModal = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
createdToken = null,
|
createdToken = null,
|
||||||
onDismissCreatedToken,
|
onDismissCreatedToken,
|
||||||
|
passkeys = [],
|
||||||
|
passkeysSupported = null,
|
||||||
|
passkeysLoading = false,
|
||||||
|
registeringPasskey = false,
|
||||||
|
revokingPasskeyId = null,
|
||||||
|
onRefreshPasskeys,
|
||||||
|
onRegisterPasskey,
|
||||||
|
onRevokePasskey,
|
||||||
}) => {
|
}) => {
|
||||||
const defaultSection = SECTIONS[0]?.id || 'webdav';
|
const defaultSection = SECTIONS[0]?.id || 'passkeys';
|
||||||
const [activeSection, setActiveSection] = useState(defaultSection);
|
const [activeSection, setActiveSection] = useState(defaultSection);
|
||||||
const [newTokenLabel, setNewTokenLabel] = useState('');
|
const [newTokenLabel, setNewTokenLabel] = useState('');
|
||||||
const [newTokenExpires, setNewTokenExpires] = useState('');
|
const [newTokenExpires, setNewTokenExpires] = useState('');
|
||||||
const [formError, setFormError] = useState(null);
|
const [formError, setFormError] = useState(null);
|
||||||
|
const [newPasskeyNickname, setNewPasskeyNickname] = useState('');
|
||||||
|
|
||||||
const handleBackdropClick = useCallback(() => {
|
const handleBackdropClick = useCallback(() => {
|
||||||
onClose?.();
|
onClose?.();
|
||||||
@@ -40,6 +53,7 @@ const SettingsModal = ({
|
|||||||
setNewTokenLabel('');
|
setNewTokenLabel('');
|
||||||
setNewTokenExpires('');
|
setNewTokenExpires('');
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
setNewPasskeyNickname('');
|
||||||
}
|
}
|
||||||
}, [open, defaultSection]);
|
}, [open, defaultSection]);
|
||||||
|
|
||||||
@@ -71,6 +85,34 @@ const SettingsModal = ({
|
|||||||
onDismissCreatedToken?.();
|
onDismissCreatedToken?.();
|
||||||
}, [onDismissCreatedToken]);
|
}, [onDismissCreatedToken]);
|
||||||
|
|
||||||
|
const handlePasskeyRefresh = useCallback(() => {
|
||||||
|
onRefreshPasskeys?.();
|
||||||
|
}, [onRefreshPasskeys]);
|
||||||
|
|
||||||
|
const handlePasskeyRegister = useCallback(
|
||||||
|
async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const nickname = newPasskeyNickname.trim();
|
||||||
|
const result = await onRegisterPasskey?.({ nickname });
|
||||||
|
if (result?.ok) {
|
||||||
|
setNewPasskeyNickname('');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[newPasskeyNickname, onRegisterPasskey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePasskeyRevoke = useCallback(
|
||||||
|
async (passkey) => {
|
||||||
|
if (!passkey?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reasonInput = window.prompt('Optional reason for revoking this passkey:', '');
|
||||||
|
const reason = reasonInput ? reasonInput.trim() : undefined;
|
||||||
|
await onRevokePasskey?.(passkey.id, reason);
|
||||||
|
},
|
||||||
|
[onRevokePasskey],
|
||||||
|
);
|
||||||
|
|
||||||
const handleCreateToken = useCallback(
|
const handleCreateToken = useCallback(
|
||||||
async (event) => {
|
async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -232,6 +274,126 @@ const SettingsModal = ({
|
|||||||
handleDismissSecret,
|
handleDismissSecret,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const renderPasskeysSection = useMemo(() => {
|
||||||
|
const hasPasskeys = Array.isArray(passkeys) && passkeys.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={handlePasskeyRefresh}
|
||||||
|
disabled={passkeysLoading}
|
||||||
|
>
|
||||||
|
{passkeysLoading ? 'Refreshing…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{passkeysSupported === false ? (
|
||||||
|
<p className="settings-empty">Passkeys are not enabled for this account.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<form className="settings-form" onSubmit={handlePasskeyRegister}>
|
||||||
|
<div className="settings-form__field">
|
||||||
|
<label htmlFor="passkey-nickname">Nickname (optional)</label>
|
||||||
|
<input
|
||||||
|
id="passkey-nickname"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. MacBook"
|
||||||
|
value={newPasskeyNickname}
|
||||||
|
onChange={(event) => setNewPasskeyNickname(event.target.value)}
|
||||||
|
disabled={registeringPasskey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="settings-form__actions">
|
||||||
|
<button type="submit" disabled={registeringPasskey}>
|
||||||
|
{registeringPasskey ? 'Registering…' : 'Register passkey'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{passkeysLoading && !hasPasskeys ? (
|
||||||
|
<p className="settings-empty">Loading passkeys…</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!passkeysLoading && !hasPasskeys ? (
|
||||||
|
<p className="settings-empty">No passkeys registered yet.</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasPasskeys ? (
|
||||||
|
<table className="settings-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Nickname</th>
|
||||||
|
<th scope="col">Created</th>
|
||||||
|
<th scope="col">Last used</th>
|
||||||
|
<th scope="col">Transports</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{passkeys.map((passkey) => {
|
||||||
|
const createdAt = passkey.created_at || passkey.createdAt;
|
||||||
|
const lastUsedAt = passkey.last_used_at || passkey.lastUsedAt;
|
||||||
|
const revokedAt = passkey.revoked_at || passkey.revokedAt;
|
||||||
|
const revokedReason = passkey.revoked_reason || passkey.revokedReason;
|
||||||
|
const revoked = Boolean(revokedAt);
|
||||||
|
const transports = Array.isArray(passkey.transports)
|
||||||
|
? passkey.transports.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={passkey.id} className={revoked ? 'is-revoked' : undefined}>
|
||||||
|
<td>{passkey.nickname || '—'}</td>
|
||||||
|
<td>{formatDateTime(createdAt)}</td>
|
||||||
|
<td>{formatDateTime(lastUsedAt)}</td>
|
||||||
|
<td>{transports.length ? transports.join(', ') : '—'}</td>
|
||||||
|
<td>
|
||||||
|
{revoked
|
||||||
|
? revokedReason
|
||||||
|
? `Revoked (${revokedReason})`
|
||||||
|
: 'Revoked'
|
||||||
|
: 'Active'}
|
||||||
|
</td>
|
||||||
|
<td className="settings-table__actions">
|
||||||
|
{revoked ? (
|
||||||
|
<span className="settings-status">Revoked</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
onClick={() => handlePasskeyRevoke(passkey)}
|
||||||
|
disabled={revokingPasskeyId === passkey.id}
|
||||||
|
>
|
||||||
|
{revokingPasskeyId === passkey.id ? 'Revoking…' : 'Revoke'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
passkeys,
|
||||||
|
passkeysLoading,
|
||||||
|
passkeysSupported,
|
||||||
|
registeringPasskey,
|
||||||
|
revokingPasskeyId,
|
||||||
|
newPasskeyNickname,
|
||||||
|
formatDateTime,
|
||||||
|
handlePasskeyRefresh,
|
||||||
|
handlePasskeyRegister,
|
||||||
|
handlePasskeyRevoke,
|
||||||
|
]);
|
||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -268,7 +430,11 @@ const SettingsModal = ({
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="settings-modal__content">
|
<div className="settings-modal__content">
|
||||||
{activeSection === 'webdav' ? renderWebdavSection : <p>Select a settings section.</p>}
|
{activeSection === 'passkeys' ? renderPasskeysSection : null}
|
||||||
|
{activeSection === 'webdav' ? renderWebdavSection : null}
|
||||||
|
{activeSection !== 'passkeys' && activeSection !== 'webdav' ? (
|
||||||
|
<p>Select a settings section.</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
isWebAuthnAvailable,
|
||||||
|
preparePublicKeyCreationOptions,
|
||||||
|
serializeRegistrationCredential,
|
||||||
|
} from '../utils/webauthn';
|
||||||
|
|
||||||
|
const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }) => {
|
||||||
|
const [passkeys, setPasskeys] = useState([]);
|
||||||
|
const [passkeysSupported, setPasskeysSupported] = useState(null);
|
||||||
|
const [passkeysLoading, setPasskeysLoading] = useState(false);
|
||||||
|
const [registeringPasskey, setRegisteringPasskey] = useState(false);
|
||||||
|
const [revokingPasskeyId, setRevokingPasskeyId] = useState(null);
|
||||||
|
|
||||||
|
const refreshPasskeys = useCallback(async () => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPasskeysLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/profile/passkeys');
|
||||||
|
setPasskeys(Array.isArray(data) ? data : []);
|
||||||
|
setPasskeysSupported(true);
|
||||||
|
} catch (error) {
|
||||||
|
const status = error?.response?.status;
|
||||||
|
if (status === 400 || status === 404) {
|
||||||
|
setPasskeysSupported(false);
|
||||||
|
setPasskeys([]);
|
||||||
|
} else {
|
||||||
|
notifyApiError(error, 'Failed to load passkeys.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPasskeysLoading(false);
|
||||||
|
}
|
||||||
|
}, [api, notifyApiError, token]);
|
||||||
|
|
||||||
|
const registerPasskey = useCallback(
|
||||||
|
async ({ nickname } = {}) => {
|
||||||
|
if (!isWebAuthnAvailable()) {
|
||||||
|
setPasskeysSupported(false);
|
||||||
|
setStatusMessage('Passkeys are not supported in this browser.', 'error');
|
||||||
|
return { ok: false, reason: 'unsupported' };
|
||||||
|
}
|
||||||
|
if (registeringPasskey) {
|
||||||
|
return { ok: false, reason: 'busy' };
|
||||||
|
}
|
||||||
|
|
||||||
|
setRegisteringPasskey(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/auth/passkeys/register/start', {});
|
||||||
|
const challengeId = data?.challengeId || data?.challenge_id;
|
||||||
|
const publicKeyOptions =
|
||||||
|
data?.publicKey
|
||||||
|
|| data?.public_key
|
||||||
|
|| data?.challenge?.publicKey
|
||||||
|
|| data?.publicKeyCredentialCreationOptions;
|
||||||
|
|
||||||
|
if (!challengeId || !publicKeyOptions) {
|
||||||
|
throw new Error('Invalid passkey challenge response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions });
|
||||||
|
const credential = await navigator.credentials.create({ publicKey });
|
||||||
|
|
||||||
|
if (!credential) {
|
||||||
|
return { ok: false, reason: 'cancelled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized = serializeRegistrationCredential(credential);
|
||||||
|
const payload = {
|
||||||
|
challengeId,
|
||||||
|
credential: serialized,
|
||||||
|
};
|
||||||
|
const trimmedNickname = nickname?.trim();
|
||||||
|
if (trimmedNickname) {
|
||||||
|
payload.nickname = trimmedNickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.post('/auth/passkeys/register/finish', payload);
|
||||||
|
await refreshPasskeys();
|
||||||
|
setPasskeysSupported(true);
|
||||||
|
setStatusMessage('Passkey registered.', 'success');
|
||||||
|
return { ok: true };
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'NotAllowedError') {
|
||||||
|
setStatusMessage('Passkey registration cancelled.', 'info');
|
||||||
|
return { ok: false, reason: 'cancelled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = error?.response?.status;
|
||||||
|
if (status === 400 || status === 404) {
|
||||||
|
setPasskeysSupported(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = error?.response?.data?.error || 'Failed to register passkey.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
return { ok: false, reason: 'error', message };
|
||||||
|
} finally {
|
||||||
|
setRegisteringPasskey(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, notifyApiError, refreshPasskeys, registeringPasskey, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const revokePasskey = useCallback(
|
||||||
|
async (passkeyId, reason) => {
|
||||||
|
if (!passkeyId) {
|
||||||
|
return { ok: false, reason: 'missing-id' };
|
||||||
|
}
|
||||||
|
setRevokingPasskeyId(passkeyId);
|
||||||
|
try {
|
||||||
|
const query = reason ? `?reason=${encodeURIComponent(reason)}` : '';
|
||||||
|
await api.delete(`/profile/passkeys/${passkeyId}${query}`);
|
||||||
|
await refreshPasskeys();
|
||||||
|
setStatusMessage('Passkey revoked.', 'success');
|
||||||
|
return { ok: true };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.response?.data?.error || 'Failed to revoke passkey.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
return { ok: false, reason: 'error', message };
|
||||||
|
} finally {
|
||||||
|
setRevokingPasskeyId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, notifyApiError, refreshPasskeys, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
passkeys,
|
||||||
|
passkeysSupported,
|
||||||
|
passkeysLoading,
|
||||||
|
registeringPasskey,
|
||||||
|
revokingPasskeyId,
|
||||||
|
refreshPasskeys,
|
||||||
|
registerPasskey,
|
||||||
|
revokePasskey,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default usePasskeys;
|
||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
|
PlusIcon,
|
||||||
|
FolderPlusIcon,
|
||||||
|
RestoreIcon,
|
||||||
} from '../ui/icons';
|
} from '../ui/icons';
|
||||||
|
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
@@ -138,6 +141,10 @@ const Sidebar = ({
|
|||||||
onFolderDragStart,
|
onFolderDragStart,
|
||||||
onFolderDragEnd,
|
onFolderDragEnd,
|
||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
|
onCreateFolder,
|
||||||
|
creatingFolder = false,
|
||||||
|
onNeutralHueChange,
|
||||||
|
neutralHue,
|
||||||
tags = [],
|
tags = [],
|
||||||
activeTagIds = [],
|
activeTagIds = [],
|
||||||
onToggleTagFilter,
|
onToggleTagFilter,
|
||||||
@@ -146,18 +153,17 @@ const Sidebar = ({
|
|||||||
onToggleCorrespondentFilter,
|
onToggleCorrespondentFilter,
|
||||||
onManageTags,
|
onManageTags,
|
||||||
onManageCorrespondents,
|
onManageCorrespondents,
|
||||||
|
onCreateTag,
|
||||||
|
onCreateCorrespondent,
|
||||||
searchQuery = '',
|
searchQuery = '',
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onSearchSubmit,
|
onSearchSubmit,
|
||||||
onSearchClear,
|
onSearchClear,
|
||||||
isFilterActive,
|
isFilterActive,
|
||||||
appStatus,
|
|
||||||
loading,
|
|
||||||
previewActive,
|
|
||||||
onLogout,
|
onLogout,
|
||||||
status,
|
status,
|
||||||
onCollapse,
|
onCollapse,
|
||||||
tenantSlug,
|
tenantName,
|
||||||
tenants = [],
|
tenants = [],
|
||||||
activeTenantId = null,
|
activeTenantId = null,
|
||||||
onSelectTenant,
|
onSelectTenant,
|
||||||
@@ -178,6 +184,49 @@ const Sidebar = ({
|
|||||||
const activeTagSet = new Set(activeTagIds);
|
const activeTagSet = new Set(activeTagIds);
|
||||||
const handleManageTags = onManageTags || (() => {});
|
const handleManageTags = onManageTags || (() => {});
|
||||||
const handleManageCorrespondents = onManageCorrespondents || (() => {});
|
const handleManageCorrespondents = onManageCorrespondents || (() => {});
|
||||||
|
const handleCreateTag = useCallback(async () => {
|
||||||
|
const input = window.prompt('New tag name');
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await onCreateTag?.(trimmed);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[sidebar] failed to create tag', error);
|
||||||
|
}
|
||||||
|
}, [onCreateTag]);
|
||||||
|
|
||||||
|
const handleCreateCorrespondent = useCallback(async () => {
|
||||||
|
const input = window.prompt('New correspondent name');
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await onCreateCorrespondent?.(trimmed);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[sidebar] failed to create correspondent', error);
|
||||||
|
}
|
||||||
|
}, [onCreateCorrespondent]);
|
||||||
|
|
||||||
|
const handleCreateFolder = useCallback(() => {
|
||||||
|
if (creatingFolder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onCreateFolder?.();
|
||||||
|
}, [creatingFolder, onCreateFolder]);
|
||||||
|
|
||||||
|
const handleNeutralHueReset = useCallback(() => {
|
||||||
|
onNeutralHueChange?.('');
|
||||||
|
}, [onNeutralHueChange]);
|
||||||
|
|
||||||
const handleSearchInputChange = useCallback(
|
const handleSearchInputChange = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
onSearchChange?.(event.target.value);
|
onSearchChange?.(event.target.value);
|
||||||
@@ -197,6 +246,7 @@ const Sidebar = ({
|
|||||||
const tenantButtonRef = useRef(null);
|
const tenantButtonRef = useRef(null);
|
||||||
const tenantMenuRef = useRef(null);
|
const tenantMenuRef = useRef(null);
|
||||||
const [tenantMenuOpen, setTenantMenuOpen] = useState(false);
|
const [tenantMenuOpen, setTenantMenuOpen] = useState(false);
|
||||||
|
const showTenantList = tenants.length > 1;
|
||||||
|
|
||||||
const toggleTenantMenu = useCallback(() => {
|
const toggleTenantMenu = useCallback(() => {
|
||||||
const next = !tenantMenuOpen;
|
const next = !tenantMenuOpen;
|
||||||
@@ -311,7 +361,7 @@ const Sidebar = ({
|
|||||||
>
|
>
|
||||||
<span className="sidebar__title">
|
<span className="sidebar__title">
|
||||||
Papercrate
|
Papercrate
|
||||||
{tenantSlug ? <span className="sidebar__tenant"> / {tenantSlug}</span> : null}
|
{tenantName ? <span className="sidebar__tenant"> / {tenantName}</span> : null}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
className={`sidebar__title-chevron${tenantMenuOpen ? ' is-open' : ''}`}
|
className={`sidebar__title-chevron${tenantMenuOpen ? ' is-open' : ''}`}
|
||||||
@@ -319,33 +369,38 @@ const Sidebar = ({
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{tenantMenuOpen ? (
|
{tenantMenuOpen ? (
|
||||||
<div className="menu" ref={tenantMenuRef} role="menu">
|
<div
|
||||||
<div className="menu__list">
|
className={`menu${showTenantList ? '' : ' menu--simple'}`}
|
||||||
{tenants.length === 0 ? (
|
ref={tenantMenuRef}
|
||||||
<span className="menu__empty">No tenants available.</span>
|
role="menu"
|
||||||
) : (
|
aria-label="Account menu"
|
||||||
tenants.map((tenant) => {
|
>
|
||||||
const tenantId = tenant?.id || null;
|
{showTenantList ? (
|
||||||
const isActive = tenantId === activeTenantId;
|
<>
|
||||||
return (
|
<div className="menu__heading">Switch tenant</div>
|
||||||
<button
|
<div className="menu__list">
|
||||||
key={tenantId || tenant?.slug || tenant?.name}
|
{tenants.map((tenant) => {
|
||||||
type="button"
|
const tenantId = tenant?.id || null;
|
||||||
className={`menu__item${isActive ? ' active' : ''}`}
|
const isActive = tenantId === activeTenantId;
|
||||||
onClick={() => handleTenantSelect(tenant)}
|
const tenantLabel = tenant?.name || tenant?.slug || tenantId || 'Tenant';
|
||||||
role="menuitem"
|
return (
|
||||||
>
|
<button
|
||||||
<span className="menu__check-slot">
|
key={tenantId || tenantLabel}
|
||||||
{isActive ? <CheckIcon size={16} /> : null}
|
type="button"
|
||||||
</span>
|
className={`menu__item${isActive ? ' active' : ''}`}
|
||||||
<span className="menu__label">
|
onClick={() => handleTenantSelect(tenant)}
|
||||||
{tenant?.slug || tenant?.name || tenantId || 'Tenant'}
|
role="menuitem"
|
||||||
</span>
|
>
|
||||||
</button>
|
<span className="menu__check-slot">
|
||||||
);
|
{isActive ? <CheckIcon size={16} /> : null}
|
||||||
})
|
</span>
|
||||||
)}
|
<span className="menu__label">{tenantLabel}</span>
|
||||||
</div>
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<div className="menu__footer">
|
<div className="menu__footer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -405,6 +460,19 @@ const Sidebar = ({
|
|||||||
<div className="sidebar-section sidebar-section--folders">
|
<div className="sidebar-section sidebar-section--folders">
|
||||||
<div className="sidebar-section__header">
|
<div className="sidebar-section__header">
|
||||||
<h3>Folders</h3>
|
<h3>Folders</h3>
|
||||||
|
{onCreateFolder ? (
|
||||||
|
<div className="sidebar-section__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={handleCreateFolder}
|
||||||
|
aria-label="Create folder"
|
||||||
|
disabled={creatingFolder}
|
||||||
|
>
|
||||||
|
<FolderPlusIcon size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<ul className="folder-tree">
|
<ul className="folder-tree">
|
||||||
{rootNode && renderNodes([rootNode.id], 0)}
|
{rootNode && renderNodes([rootNode.id], 0)}
|
||||||
@@ -412,14 +480,26 @@ const Sidebar = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
<div className="sidebar-section__header">
|
<div className="sidebar-section__header">
|
||||||
<button
|
<h3>Tags</h3>
|
||||||
type="button"
|
<div className="sidebar-section__actions">
|
||||||
className="sidebar-section__title"
|
<button
|
||||||
onClick={handleManageTags}
|
type="button"
|
||||||
>
|
className="icon-button"
|
||||||
<h3>Tags</h3>
|
onClick={handleCreateTag}
|
||||||
|
aria-label="Create tag"
|
||||||
|
>
|
||||||
|
<PlusIcon size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={handleManageTags}
|
||||||
|
aria-label="Manage tags"
|
||||||
|
>
|
||||||
|
<SettingsIcon size={16} />
|
||||||
|
</button>
|
||||||
<span className="meta">{tags.length}</span>
|
<span className="meta">{tags.length}</span>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`sidebar-tag-cloud${
|
className={`sidebar-tag-cloud${
|
||||||
@@ -440,39 +520,50 @@ const Sidebar = ({
|
|||||||
style={style || undefined}
|
style={style || undefined}
|
||||||
onClick={() => handleToggleTag(tag.id)}
|
onClick={() => handleToggleTag(tag.id)}
|
||||||
aria-pressed={isActive}
|
aria-pressed={isActive}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
id: tag.id,
|
id: tag.id,
|
||||||
label: tag.label,
|
label: tag.label,
|
||||||
color: tag.color || null,
|
color: tag.color || null,
|
||||||
});
|
});
|
||||||
event.dataTransfer.effectAllowed = 'copy';
|
event.dataTransfer.effectAllowed = 'copy';
|
||||||
event.dataTransfer.setData('application/x-papercrate-tag', payload);
|
event.dataTransfer.setData('application/x-papercrate-tag', payload);
|
||||||
event.dataTransfer.setData('text/papercrate-tag', payload);
|
event.dataTransfer.setData('text/papercrate-tag', payload);
|
||||||
} catch (
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-empty
|
console.warn('[sidebar] Failed to set tag drag payload', error);
|
||||||
error
|
}
|
||||||
) {}
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{tag.label}
|
||||||
{tag.label}
|
</button>
|
||||||
</button>
|
);
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
<div className="sidebar-section__header">
|
<div className="sidebar-section__header">
|
||||||
<button
|
<h3>Correspondents</h3>
|
||||||
type="button"
|
<div className="sidebar-section__actions">
|
||||||
className="sidebar-section__title"
|
<button
|
||||||
onClick={handleManageCorrespondents}
|
type="button"
|
||||||
>
|
className="icon-button"
|
||||||
<h3>Correspondents</h3>
|
onClick={handleCreateCorrespondent}
|
||||||
|
aria-label="Create correspondent"
|
||||||
|
>
|
||||||
|
<PlusIcon size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={handleManageCorrespondents}
|
||||||
|
aria-label="Manage correspondents"
|
||||||
|
>
|
||||||
|
<SettingsIcon size={16} />
|
||||||
|
</button>
|
||||||
<span className="meta">{correspondents.length}</span>
|
<span className="meta">{correspondents.length}</span>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul className="sidebar-correspondent-list">
|
<ul className="sidebar-correspondent-list">
|
||||||
{sortedCorrespondents.map((correspondent) => {
|
{sortedCorrespondents.map((correspondent) => {
|
||||||
@@ -503,6 +594,35 @@ const Sidebar = ({
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
{typeof neutralHue === 'number' || typeof neutralHue === 'string' ? (
|
||||||
|
<div className="sidebar-section">
|
||||||
|
<div className="sidebar-section__header">
|
||||||
|
<h3>Theme</h3>
|
||||||
|
<div className="sidebar-section__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={handleNeutralHueReset}
|
||||||
|
aria-label="Reset neutral hue"
|
||||||
|
>
|
||||||
|
<RestoreIcon size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="sidebar-slider">
|
||||||
|
<span className="sidebar-slider__label">Neutral hue</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="360"
|
||||||
|
step="1"
|
||||||
|
value={neutralHue}
|
||||||
|
onChange={(event) => onNeutralHueChange?.(event.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="sidebar-slider__value">{neutralHue}°</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
+181
-80
@@ -1,23 +1,34 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light dark;
|
||||||
|
|
||||||
/* master warmth bias: positive = warmer, negative = cooler */
|
--neutral-hue: 260deg;
|
||||||
--warmth: 3deg; /* try +5deg for warmer, -5deg for cooler */
|
--foreground-hue-offset: 10deg;
|
||||||
|
--accent-hue-offset: -20deg;
|
||||||
|
--dark-hue-offset: 30deg;
|
||||||
|
--dark-foreground-hue-offset: 10deg;
|
||||||
|
|
||||||
/* --- Neutrals --- */
|
/* --- Neutrals --- */
|
||||||
--bg: oklch(0.97 0.001 calc(100deg + var(--warmth)));
|
--bg: oklch(0.97 0.001 var(--neutral-hue));
|
||||||
--surface: oklch(0.985 0.001 calc(100deg + var(--warmth)));
|
--surface: oklch(0.985 0.001 var(--neutral-hue));
|
||||||
--surface-subtle: oklch(0.96 0.002 calc(100deg + var(--warmth)));
|
--surface-subtle: oklch(0.96 0.002 var(--neutral-hue));
|
||||||
--fg: oklch(0.32 0.005 calc(270deg + var(--warmth)));
|
--fg: oklch(0.32 0.005 calc(var(--neutral-hue) + var(--foreground-hue-offset)));
|
||||||
--muted: oklch(0.54 0.008 calc(270deg + var(--warmth)));
|
--muted: oklch(0.54 0.008 calc(var(--neutral-hue) + var(--foreground-hue-offset)));
|
||||||
--sidebar-fg: oklch(0.56 0.007 calc(270deg + var(--warmth)));
|
--sidebar-fg: oklch(0.56 0.007 calc(var(--neutral-hue) + var(--foreground-hue-offset)));
|
||||||
--border: oklch(0.92 0.002 calc(270deg + var(--warmth)));
|
--border: oklch(0.92 0.002 calc(var(--neutral-hue) + var(--foreground-hue-offset)));
|
||||||
|
|
||||||
/* --- Accent (primary) --- */
|
/* --- Accent (primary) --- */
|
||||||
--accent: oklch(0.61 0.2 calc(250deg + var(--warmth)));
|
--accent: oklch(0.61 0.2 calc(var(--neutral-hue) + var(--foreground-hue-offset) + var(--accent-hue-offset)));
|
||||||
--accent-hover: oklch(0.70 0.02 calc(100deg + var(--warmth)));
|
--accent-hover: oklch(0.70 0.02 calc(var(--neutral-hue) + var(--foreground-hue-offset) + var(--accent-hue-offset)));
|
||||||
--on-accent: oklch(1 0 0);
|
--on-accent: oklch(1 0 0);
|
||||||
|
|
||||||
|
--folder-icon-back: color-mix(in oklch, var(--accent) 78%, black 8%);
|
||||||
|
--folder-icon-mid: color-mix(in oklch, var(--accent) 60%, white 30%);
|
||||||
|
--folder-icon-front: color-mix(in oklch, var(--accent) 42%, white 58%);
|
||||||
|
|
||||||
|
--success-hue: 140deg;
|
||||||
|
--warning-hue: 85deg;
|
||||||
|
--danger-hue: 25deg;
|
||||||
|
|
||||||
--accent-soft: color-mix(in oklch, var(--accent) 12%, transparent);
|
--accent-soft: color-mix(in oklch, var(--accent) 12%, transparent);
|
||||||
--accent-elevated: color-mix(in oklch, var(--accent) 16%, transparent);
|
--accent-elevated: color-mix(in oklch, var(--accent) 16%, transparent);
|
||||||
--accent-elevated-strong: color-mix(in oklch, var(--accent) 22%, transparent);
|
--accent-elevated-strong: color-mix(in oklch, var(--accent) 22%, transparent);
|
||||||
@@ -27,7 +38,7 @@
|
|||||||
--surface-overlay: color-mix(in oklch, white 82%, transparent);
|
--surface-overlay: color-mix(in oklch, white 82%, transparent);
|
||||||
|
|
||||||
/* -- Selection --- */
|
/* -- Selection --- */
|
||||||
--selection: oklch(0.61 0.01 calc(100deg + var(--warmth)));
|
--selection: oklch(0.61 0.01 var(--neutral-hue));
|
||||||
|
|
||||||
/* --- Derived accent states --- */
|
/* --- Derived accent states --- */
|
||||||
--selection-soft: color-mix(in oklch, var(--selection) 12%, transparent);
|
--selection-soft: color-mix(in oklch, var(--selection) 12%, transparent);
|
||||||
@@ -41,20 +52,20 @@
|
|||||||
--shadow-pop: color-mix(in oklch, black 25%, transparent);
|
--shadow-pop: color-mix(in oklch, black 25%, transparent);
|
||||||
--outline-subtle: color-mix(in oklch, black 6%, transparent);
|
--outline-subtle: color-mix(in oklch, black 6%, transparent);
|
||||||
--overlay-dark: color-mix(in oklch, black 55%, transparent);
|
--overlay-dark: color-mix(in oklch, black 55%, transparent);
|
||||||
--overlay-dim: color-mix(in oklch, oklch(0.28 0.01 calc(265deg + var(--warmth))) 20%, transparent);
|
--overlay-dim: color-mix(in oklch, oklch(0.28 0.01 calc(var(--neutral-hue) + var(--foreground-hue-offset) - 5deg)) 20%, transparent);
|
||||||
--overlay-darker: color-mix(in oklch, black 75%, transparent);
|
--overlay-darker: color-mix(in oklch, black 75%, transparent);
|
||||||
--surface-danger-subtle: color-mix(in oklch, var(--danger) 12%, transparent);
|
--surface-danger-subtle: color-mix(in oklch, var(--danger) 12%, transparent);
|
||||||
--overlay-accent-subtle: color-mix(in oklch, var(--accent) 12%, transparent);
|
--overlay-accent-subtle: color-mix(in oklch, var(--accent) 12%, transparent);
|
||||||
--border-strong: color-mix(in oklch, var(--fg) 24%, transparent);
|
--border-strong: color-mix(in oklch, var(--fg) 24%, transparent);
|
||||||
--surface-hover: color-mix(in oklch, white 10%, transparent);
|
--surface-hover: color-mix(in oklch, white 10%, transparent);
|
||||||
--overlay-accent-strong: color-mix(in oklch, var(--accent) 24%, transparent);
|
--overlay-accent-strong: color-mix(in oklch, var(--accent) 24%, transparent);
|
||||||
--overlay-backdrop: color-mix(in oklch, oklch(0.22 0.015 calc(265deg + var(--warmth))) 45%, transparent);
|
--overlay-backdrop: color-mix(in oklch, oklch(0.22 0.015 calc(var(--neutral-hue) + var(--foreground-hue-offset) - 5deg)) 45%, transparent);
|
||||||
--overlay-shadow: color-mix(in oklch, oklch(0.22 0.015 calc(265deg + var(--warmth))) 18%, transparent);
|
--overlay-shadow: color-mix(in oklch, oklch(0.22 0.015 calc(var(--neutral-hue) + var(--foreground-hue-offset) - 5deg)) 18%, transparent);
|
||||||
|
|
||||||
/* --- Semantic colors (relative warmth) --- */
|
/* --- Semantic colors --- */
|
||||||
--success: oklch(0.62 0.18 calc(190deg + var(--warmth)));
|
--success: oklch(0.68 0.16 var(--success-hue));
|
||||||
--warning: oklch(0.72 0.17 calc(95deg + var(--warmth)));
|
--warning: oklch(0.76 0.17 var(--warning-hue));
|
||||||
--danger: oklch(0.60 0.18 calc(35deg + var(--warmth)));
|
--danger: oklch(0.64 0.2 var(--danger-hue));
|
||||||
|
|
||||||
--success-subtle: color-mix(in oklch, var(--success) 12%, transparent);
|
--success-subtle: color-mix(in oklch, var(--success) 12%, transparent);
|
||||||
--warning-subtle: color-mix(in oklch, var(--warning) 12%, transparent);
|
--warning-subtle: color-mix(in oklch, var(--warning) 12%, transparent);
|
||||||
@@ -67,12 +78,12 @@
|
|||||||
--row-active-bg: color-mix(in oklch, var(--selection) 12%, transparent);
|
--row-active-bg: color-mix(in oklch, var(--selection) 12%, transparent);
|
||||||
--sidebar-hover-bg: color-mix(in oklch, var(--selection) 8%, transparent);
|
--sidebar-hover-bg: color-mix(in oklch, var(--selection) 8%, transparent);
|
||||||
--sidebar-active-bg: color-mix(in oklch, var(--selection) 16%, transparent);
|
--sidebar-active-bg: color-mix(in oklch, var(--selection) 16%, transparent);
|
||||||
--selection-ring: oklch(0.78 0.16 calc(260deg + var(--warmth)));
|
--selection-ring: oklch(0.78 0.16 calc(var(--neutral-hue) + var(--foreground-hue-offset)));
|
||||||
--sidebar-active-pill-border: color-mix(in oklch, var(--accent) 64%, transparent);
|
--sidebar-active-pill-border: color-mix(in oklch, var(--accent) 64%, transparent);
|
||||||
--link: var(--accent);
|
--link: var(--accent);
|
||||||
--link-visited: color-mix(in oklch, var(--accent) 75%, var(--fg) 25%);
|
--link-visited: color-mix(in oklch, var(--accent) 75%, var(--fg) 25%);
|
||||||
--preview-nav-bg: oklch(0.18 0.05 calc(270deg + var(--warmth)));
|
--preview-nav-bg: oklch(0.18 0.05 calc(var(--neutral-hue) + var(--foreground-hue-offset)));
|
||||||
--preview-nav-bg-hover: oklch(0.14 0.05 calc(270deg + var(--warmth)));
|
--preview-nav-bg-hover: oklch(0.14 0.05 calc(var(--neutral-hue) + var(--foreground-hue-offset)));
|
||||||
--preview-nav-fg: var(--on-accent);
|
--preview-nav-fg: var(--on-accent);
|
||||||
--text-on-dark: color-mix(in oklch, white 90%, transparent);
|
--text-on-dark: color-mix(in oklch, white 90%, transparent);
|
||||||
--surface-ink-soft: color-mix(in oklch, var(--fg) 8%, transparent);
|
--surface-ink-soft: color-mix(in oklch, var(--fg) 8%, transparent);
|
||||||
@@ -85,6 +96,84 @@
|
|||||||
--documents-grid-title-size: 0.8rem;
|
--documents-grid-title-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--dark-neutral-hue: calc(var(--neutral-hue) + var(--dark-hue-offset));
|
||||||
|
--dark-foreground-hue: calc(var(--neutral-hue) + var(--dark-foreground-hue-offset));
|
||||||
|
|
||||||
|
--bg: oklch(0.15 0.01 var(--dark-neutral-hue));
|
||||||
|
--surface: oklch(0.19 0.012 var(--dark-neutral-hue));
|
||||||
|
--surface-subtle: oklch(0.23 0.012 var(--dark-neutral-hue));
|
||||||
|
--fg: oklch(0.89 0.015 var(--dark-foreground-hue));
|
||||||
|
--muted: oklch(0.72 0.02 var(--dark-foreground-hue));
|
||||||
|
--sidebar-fg: oklch(0.78 0.02 var(--dark-foreground-hue));
|
||||||
|
--border: oklch(0.33 0.01 var(--dark-foreground-hue));
|
||||||
|
|
||||||
|
--accent: oklch(0.75 0.2 calc(var(--dark-foreground-hue) + var(--accent-hue-offset)));
|
||||||
|
--accent-hover: oklch(0.82 0.16 calc(var(--dark-foreground-hue) + var(--accent-hue-offset)));
|
||||||
|
--on-accent: oklch(0.15 0.015 var(--dark-foreground-hue));
|
||||||
|
|
||||||
|
--folder-icon-back: color-mix(in oklch, var(--accent) 72%, black 12%);
|
||||||
|
--folder-icon-mid: color-mix(in oklch, var(--accent) 50%, white 24%);
|
||||||
|
--folder-icon-front: color-mix(in oklch, var(--accent) 30%, white 50%);
|
||||||
|
|
||||||
|
--accent-soft: color-mix(in oklch, var(--accent) 22%, transparent);
|
||||||
|
--accent-elevated: color-mix(in oklch, var(--accent) 28%, transparent);
|
||||||
|
--accent-elevated-strong: color-mix(in oklch, var(--accent) 38%, transparent);
|
||||||
|
--accent-outline: color-mix(in oklch, var(--accent) 55%, transparent);
|
||||||
|
--accent-outline-strong: color-mix(in oklch, var(--accent) 90%, transparent);
|
||||||
|
--accent-focus: color-mix(in oklch, var(--accent) 80%, transparent);
|
||||||
|
--surface-overlay: color-mix(in oklch, black 60%, transparent);
|
||||||
|
|
||||||
|
--selection: oklch(0.44 0.04 220deg);
|
||||||
|
--selection-soft: color-mix(in oklch, var(--selection) 18%, transparent);
|
||||||
|
|
||||||
|
--shadow-faint: color-mix(in oklch, black 35%, transparent);
|
||||||
|
--shadow-soft: color-mix(in oklch, black 50%, transparent);
|
||||||
|
--shadow-medium: color-mix(in oklch, black 65%, transparent);
|
||||||
|
--shadow-strong: color-mix(in oklch, black 80%, transparent);
|
||||||
|
--shadow-deep: color-mix(in oklch, black 90%, transparent);
|
||||||
|
--shadow-pop: color-mix(in oklch, black 70%, transparent);
|
||||||
|
--outline-subtle: color-mix(in oklch, white 10%, transparent);
|
||||||
|
--overlay-dark: color-mix(in oklch, black 70%, transparent);
|
||||||
|
--overlay-dim: color-mix(in oklch, oklch(0.42 0.04 var(--dark-neutral-hue)) 35%, transparent);
|
||||||
|
--overlay-darker: color-mix(in oklch, black 85%, transparent);
|
||||||
|
--surface-danger-subtle: color-mix(in oklch, var(--danger) 26%, transparent);
|
||||||
|
--overlay-accent-subtle: color-mix(in oklch, var(--accent) 32%, transparent);
|
||||||
|
--border-strong: color-mix(in oklch, var(--fg) 30%, transparent);
|
||||||
|
--surface-hover: color-mix(in oklch, white 6%, transparent);
|
||||||
|
--overlay-accent-strong: color-mix(in oklch, var(--accent) 55%, transparent);
|
||||||
|
--overlay-backdrop: color-mix(in oklch, oklch(0.1 0.015 var(--dark-neutral-hue)) 70%, transparent);
|
||||||
|
--overlay-shadow: color-mix(in oklch, oklch(0.12 0.015 var(--dark-neutral-hue)) 55%, transparent);
|
||||||
|
|
||||||
|
--success: oklch(0.62 0.16 var(--success-hue));
|
||||||
|
--warning: oklch(0.68 0.17 var(--warning-hue));
|
||||||
|
--danger: oklch(0.60 0.2 var(--danger-hue));
|
||||||
|
|
||||||
|
--success-subtle: color-mix(in oklch, var(--success) 20%, transparent);
|
||||||
|
--warning-subtle: color-mix(in oklch, var(--warning) 20%, transparent);
|
||||||
|
--danger-border: color-mix(in oklch, var(--danger) 45%, transparent);
|
||||||
|
--danger-soft: color-mix(in oklch, var(--danger) 24%, transparent);
|
||||||
|
--danger-subtle: color-mix(in oklch, var(--danger) 18%, transparent);
|
||||||
|
|
||||||
|
--row-hover-bg: color-mix(in oklch, white 4%, transparent);
|
||||||
|
--row-active-bg: color-mix(in oklch, var(--selection) 20%, transparent);
|
||||||
|
--sidebar-hover-bg: color-mix(in oklch, white 6%, transparent);
|
||||||
|
--sidebar-active-bg: color-mix(in oklch, var(--selection) 26%, transparent);
|
||||||
|
--selection-ring: oklch(0.68 0.16 var(--dark-foreground-hue));
|
||||||
|
--sidebar-active-pill-border: color-mix(in oklch, var(--accent) 72%, transparent);
|
||||||
|
--link: color-mix(in oklch, var(--accent) 92%, transparent);
|
||||||
|
--link-visited: color-mix(in oklch, var(--accent) 70%, white 20%);
|
||||||
|
--preview-nav-bg: oklch(0.3 0.04 var(--dark-neutral-hue));
|
||||||
|
--preview-nav-bg-hover: oklch(0.35 0.04 var(--dark-neutral-hue));
|
||||||
|
--preview-nav-fg: var(--fg);
|
||||||
|
--text-on-dark: color-mix(in oklch, white 92%, transparent);
|
||||||
|
--surface-ink-soft: color-mix(in oklch, white 12%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -1024,6 +1113,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-node {
|
.folder-node {
|
||||||
@@ -1207,6 +1297,13 @@ button.danger:hover:not([disabled]) {
|
|||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu__heading {
|
||||||
|
padding: 0.5rem 1rem 0.25rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.menu button.menu__item,
|
.menu button.menu__item,
|
||||||
.menu .menu__item {
|
.menu .menu__item {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1278,6 +1375,10 @@ button.danger:hover:not([disabled]) {
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu--simple .menu__footer {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
.menu__settings {
|
.menu__settings {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1406,6 +1507,37 @@ button.danger:hover:not([disabled]) {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-section__actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section__actions .meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-slider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-slider input[type='range'] {
|
||||||
|
flex: 1;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-slider__value {
|
||||||
|
min-width: 3rem;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--fg);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-item {
|
.sidebar-item {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 0.16rem 0.26rem;
|
padding: 0.16rem 0.26rem;
|
||||||
@@ -1511,7 +1643,6 @@ button.danger:hover:not([disabled]) {
|
|||||||
|
|
||||||
.sidebar-correspondent-item:hover {
|
.sidebar-correspondent-item:hover {
|
||||||
background: var(--sidebar-hover-bg);
|
background: var(--sidebar-hover-bg);
|
||||||
box-shadow: 0 1px 3px var(--shadow-soft);
|
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1522,7 +1653,6 @@ button.danger:hover:not([disabled]) {
|
|||||||
|
|
||||||
.sidebar-correspondent-item.active {
|
.sidebar-correspondent-item.active {
|
||||||
background: var(--sidebar-active-bg);
|
background: var(--sidebar-active-bg);
|
||||||
box-shadow: 0 0 0 1px var(--sidebar-active-pill-border);
|
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1748,10 +1878,6 @@ button.danger:hover:not([disabled]) {
|
|||||||
transition: background 0.15s ease;
|
transition: background 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.documents-panel tbody tr:hover {
|
|
||||||
background: var(--surface-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.documents-panel tbody tr.folder {
|
.documents-panel tbody tr.folder {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -1805,10 +1931,9 @@ button.danger:hover:not([disabled]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.document-card.selected .document-thumbnail-wrapper {
|
.document-card.selected .document-thumbnail-wrapper {
|
||||||
background-color: var(--accent-soft);
|
background-color: var(--selection-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-card:hover .document-thumbnail-wrapper,
|
|
||||||
.document-card:focus-visible .document-thumbnail-wrapper {
|
.document-card:focus-visible .document-thumbnail-wrapper {
|
||||||
background-color: var(--selection-ring);
|
background-color: var(--selection-ring);
|
||||||
}
|
}
|
||||||
@@ -1922,7 +2047,6 @@ button.danger:hover:not([disabled]) {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-decoration-thickness: 1.5px;
|
text-decoration-thickness: 1.5px;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-correspondent-link:not(.is-static):focus-visible {
|
.doc-correspondent-link:not(.is-static):focus-visible {
|
||||||
@@ -2120,54 +2244,6 @@ button.danger:hover:not([disabled]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.detail-ocr__content {
|
|
||||||
max-height: 60vh;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: var(--surface-soft);
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: inset 0 1px 2px var(--shadow-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-ocr__content pre {
|
|
||||||
margin: 0;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-ocr__frame {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 360px;
|
|
||||||
height: 60vh;
|
|
||||||
background: var(--surface-subtle);
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: inset 0 1px 2px var(--shadow-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal__body {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal--ocr {
|
|
||||||
width: min(720px, 100%);
|
|
||||||
max-height: 80vh;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal--ocr .modal__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal--ocr .modal__body {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-pane {
|
.preview-pane {
|
||||||
margin-top: 0.4rem;
|
margin-top: 0.4rem;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@@ -2743,6 +2819,10 @@ form.inline {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal__body {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.modal h3 {
|
.modal h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
@@ -2836,6 +2916,27 @@ form.inline {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-card__fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__passkey-button {
|
||||||
|
margin-top: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__signup-button {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.login-card .status-banner {
|
.login-card .status-banner {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ import {
|
|||||||
IconTextScan2,
|
IconTextScan2,
|
||||||
IconFolderPlus,
|
IconFolderPlus,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
|
IconRestore,
|
||||||
IconMinusVertical,
|
IconMinusVertical,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconX,
|
IconX,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
|
IconPlus,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import FolderSvg from '../assets/folder.svg';
|
import FolderSvg from '../assets/folder.svg';
|
||||||
|
|
||||||
@@ -170,6 +172,15 @@ export const RefreshIcon = ({ className, size = '1em', stroke = 1.6, ...rest })
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const RestoreIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconRestore
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
export const ArrowUpIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
export const ArrowUpIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
<IconArrowUp
|
<IconArrowUp
|
||||||
className={composeClassName('icon', className)}
|
className={composeClassName('icon', className)}
|
||||||
@@ -206,6 +217,15 @@ export const SettingsIcon = ({ className, size = '1em', stroke = 1.6, ...rest })
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const PlusIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconPlus
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
export const CheckIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
export const CheckIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
<IconCheck
|
<IconCheck
|
||||||
className={composeClassName('icon', className)}
|
className={composeClassName('icon', className)}
|
||||||
|
|||||||
@@ -4,37 +4,6 @@ const clamp01 = (value) => Math.min(1, Math.max(0, value));
|
|||||||
|
|
||||||
const clampRange = (value, min, max) => Math.min(max, Math.max(min, value));
|
const clampRange = (value, min, max) => Math.min(max, Math.max(min, value));
|
||||||
|
|
||||||
const gammaEncode = (channel) =>
|
|
||||||
channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055;
|
|
||||||
|
|
||||||
const oklchToHex = (l, c, h) => {
|
|
||||||
const hr = (h * Math.PI) / 180;
|
|
||||||
const a = Math.cos(hr) * c;
|
|
||||||
const b = Math.sin(hr) * c;
|
|
||||||
|
|
||||||
const l1 = l + 0.3963377774 * a + 0.2158037573 * b;
|
|
||||||
const m1 = l - 0.1055613458 * a - 0.0638541728 * b;
|
|
||||||
const s1 = l - 0.0894841775 * a - 1.291485548 * b;
|
|
||||||
|
|
||||||
const l3 = l1 ** 3;
|
|
||||||
const m3 = m1 ** 3;
|
|
||||||
const s3 = s1 ** 3;
|
|
||||||
|
|
||||||
const r = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3;
|
|
||||||
const g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3;
|
|
||||||
const bLin = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3;
|
|
||||||
|
|
||||||
if ([r, g, bLin].some((channel) => channel < 0 || channel > 1)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sr = Math.round(clamp01(gammaEncode(r)) * 255);
|
|
||||||
const sg = Math.round(clamp01(gammaEncode(g)) * 255);
|
|
||||||
const sb = Math.round(clamp01(gammaEncode(bLin)) * 255);
|
|
||||||
|
|
||||||
return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hslToHex = (h, s, l) => {
|
const hslToHex = (h, s, l) => {
|
||||||
const normalizedH = ((h % 360) + 360) % 360;
|
const normalizedH = ((h % 360) + 360) % 360;
|
||||||
const sat = clamp01(s);
|
const sat = clamp01(s);
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { createAssetView, resolveDocumentAssetUrl } from '../asset_manager';
|
||||||
|
|
||||||
|
const BASE_FETCH_OPTIONS = { start: 1, limit: 1 };
|
||||||
|
|
||||||
|
const pickAsset = (doc, getDocumentAsset) => {
|
||||||
|
if (!doc || typeof getDocumentAsset !== 'function') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getDocumentAsset(doc, 'ocr-text') || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resolveOcrTextUrl({
|
||||||
|
document,
|
||||||
|
ensurePreviewData,
|
||||||
|
getDocumentAsset,
|
||||||
|
ensureAssetUrl,
|
||||||
|
}) {
|
||||||
|
if (!document?.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let docRef = document;
|
||||||
|
let asset = pickAsset(docRef, getDocumentAsset);
|
||||||
|
|
||||||
|
if (!asset && typeof ensurePreviewData === 'function') {
|
||||||
|
const refreshed = await ensurePreviewData(docRef.id);
|
||||||
|
if (refreshed) {
|
||||||
|
docRef = refreshed;
|
||||||
|
}
|
||||||
|
asset = pickAsset(docRef, getDocumentAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!asset) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseView = createAssetView(asset);
|
||||||
|
const hasUrl = Boolean(baseView.getPrimaryUrl());
|
||||||
|
|
||||||
|
let entry = asset;
|
||||||
|
if (typeof ensureAssetUrl === 'function') {
|
||||||
|
const ensureOptions = { ...BASE_FETCH_OPTIONS, force: !hasUrl };
|
||||||
|
entry = (await ensureAssetUrl(docRef.id, asset, ensureOptions)) || asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensuredView = createAssetView(entry);
|
||||||
|
const directUrl = ensuredView.getPrimaryUrl();
|
||||||
|
if (directUrl) {
|
||||||
|
return directUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
resolveDocumentAssetUrl(docRef, 'ocr-text', {
|
||||||
|
ensureAssetUrl,
|
||||||
|
getAsset: getDocumentAsset,
|
||||||
|
ensureOptions: { ...BASE_FETCH_OPTIONS, force: true },
|
||||||
|
objectOrdinal: 1,
|
||||||
|
}) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openOcrTextInNewTab(options) {
|
||||||
|
const popup = window.open('', '_blank');
|
||||||
|
const popupAvailable = Boolean(popup);
|
||||||
|
|
||||||
|
if (popupAvailable) {
|
||||||
|
try {
|
||||||
|
popup.opener = null;
|
||||||
|
} catch (error) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = await resolveOcrTextUrl(options);
|
||||||
|
if (!url) {
|
||||||
|
if (popupAvailable) {
|
||||||
|
popup.close();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (popupAvailable) {
|
||||||
|
try {
|
||||||
|
popup.location.replace(url);
|
||||||
|
} catch (navigationError) {
|
||||||
|
try {
|
||||||
|
popup.location.href = url;
|
||||||
|
} catch (secondError) {
|
||||||
|
popup.close();
|
||||||
|
throw secondError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const finalWindow = window.open(url, '_blank');
|
||||||
|
if (finalWindow) {
|
||||||
|
try {
|
||||||
|
finalWindow.opener = null;
|
||||||
|
} catch (error) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (popupAvailable) {
|
||||||
|
popup.close();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
const base64urlToBase64 = (value = '') => {
|
||||||
|
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padding = normalized.length % 4;
|
||||||
|
if (padding === 0) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
const padLength = 4 - padding;
|
||||||
|
return normalized + '='.repeat(padLength);
|
||||||
|
};
|
||||||
|
|
||||||
|
const base64ToBase64url = (value = '') =>
|
||||||
|
value.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||||
|
|
||||||
|
const decodeBase64 = (value) => {
|
||||||
|
if (typeof window !== 'undefined' && typeof window.atob === 'function') {
|
||||||
|
return window.atob(value);
|
||||||
|
}
|
||||||
|
const bufferCtor = typeof globalThis !== 'undefined' ? globalThis.Buffer : undefined;
|
||||||
|
if (bufferCtor) {
|
||||||
|
return bufferCtor.from(value, 'base64').toString('binary');
|
||||||
|
}
|
||||||
|
throw new Error('No base64 decoder available.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const encodeBase64 = (binary) => {
|
||||||
|
if (typeof window !== 'undefined' && typeof window.btoa === 'function') {
|
||||||
|
return window.btoa(binary);
|
||||||
|
}
|
||||||
|
const bufferCtor = typeof globalThis !== 'undefined' ? globalThis.Buffer : undefined;
|
||||||
|
if (bufferCtor) {
|
||||||
|
return bufferCtor.from(binary, 'binary').toString('base64');
|
||||||
|
}
|
||||||
|
throw new Error('No base64 encoder available.');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const base64urlToUint8Array = (value) => {
|
||||||
|
const base64 = base64urlToBase64(value || '');
|
||||||
|
const binary = decodeBase64(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const arrayBufferToBase64url = (buffer) => {
|
||||||
|
const bytes = new Uint8Array(buffer || []);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.length; i += 1) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
const base64 = encodeBase64(binary);
|
||||||
|
return base64ToBase64url(base64);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isWebAuthnAvailable = () =>
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
&& typeof navigator !== 'undefined'
|
||||||
|
&& navigator.credentials
|
||||||
|
&& typeof navigator.credentials.create === 'function'
|
||||||
|
&& typeof navigator.credentials.get === 'function';
|
||||||
|
|
||||||
|
export const preparePublicKeyCreationOptions = (challengeResponse) => {
|
||||||
|
if (!challengeResponse || !challengeResponse.publicKey) {
|
||||||
|
throw new Error('Missing publicKey challenge options.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = { ...challengeResponse.publicKey };
|
||||||
|
|
||||||
|
if (publicKey.challenge) {
|
||||||
|
publicKey.challenge = base64urlToUint8Array(publicKey.challenge);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publicKey.user?.id) {
|
||||||
|
publicKey.user = {
|
||||||
|
...publicKey.user,
|
||||||
|
id: base64urlToUint8Array(publicKey.user.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(publicKey.excludeCredentials)) {
|
||||||
|
publicKey.excludeCredentials = publicKey.excludeCredentials.map((descriptor) => ({
|
||||||
|
...descriptor,
|
||||||
|
id: base64urlToUint8Array(descriptor.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publicKey.authenticatorSelection?.residentKey === 'discouraged' && !publicKey.authenticatorSelection.requireResidentKey) {
|
||||||
|
delete publicKey.authenticatorSelection.requireResidentKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const preparePublicKeyRequestOptions = (challengeResponse) => {
|
||||||
|
if (!challengeResponse || !challengeResponse.publicKey) {
|
||||||
|
throw new Error('Missing publicKey request options.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = { ...challengeResponse.publicKey };
|
||||||
|
|
||||||
|
if (publicKey.challenge) {
|
||||||
|
publicKey.challenge = base64urlToUint8Array(publicKey.challenge);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(publicKey.allowCredentials)) {
|
||||||
|
publicKey.allowCredentials = publicKey.allowCredentials.map((descriptor) => ({
|
||||||
|
...descriptor,
|
||||||
|
id: base64urlToUint8Array(descriptor.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeRegistrationCredential = (credential) => {
|
||||||
|
if (!credential) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transports =
|
||||||
|
typeof credential?.response?.getTransports === 'function'
|
||||||
|
? credential.response.getTransports()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: credential.id,
|
||||||
|
type: credential.type,
|
||||||
|
rawId: arrayBufferToBase64url(credential.rawId),
|
||||||
|
response: {
|
||||||
|
clientDataJSON: arrayBufferToBase64url(credential.response.clientDataJSON),
|
||||||
|
attestationObject: arrayBufferToBase64url(credential.response.attestationObject),
|
||||||
|
transports: transports && transports.length ? Array.from(transports) : undefined,
|
||||||
|
},
|
||||||
|
clientExtensionResults:
|
||||||
|
typeof credential.getClientExtensionResults === 'function'
|
||||||
|
? credential.getClientExtensionResults()
|
||||||
|
: {},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeAuthenticationCredential = (credential) => {
|
||||||
|
if (!credential) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: credential.id,
|
||||||
|
type: credential.type,
|
||||||
|
rawId: arrayBufferToBase64url(credential.rawId),
|
||||||
|
response: {
|
||||||
|
clientDataJSON: arrayBufferToBase64url(credential.response.clientDataJSON),
|
||||||
|
authenticatorData: arrayBufferToBase64url(credential.response.authenticatorData),
|
||||||
|
signature: arrayBufferToBase64url(credential.response.signature),
|
||||||
|
userHandle: credential.response.userHandle
|
||||||
|
? arrayBufferToBase64url(credential.response.userHandle)
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
clientExtensionResults:
|
||||||
|
typeof credential.getClientExtensionResults === 'function'
|
||||||
|
? credential.getClientExtensionResults()
|
||||||
|
: {},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -48,7 +48,7 @@ module.exports = {
|
|||||||
open: true,
|
open: true,
|
||||||
proxy: [
|
proxy: [
|
||||||
{
|
{
|
||||||
context: ['/api'],
|
context: ['/api', '/download'],
|
||||||
target: 'http://127.0.0.1:3000',
|
target: 'http://127.0.0.1:3000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
version: 0.8
|
|
||||||
index_id: documents
|
|
||||||
doc_mapping:
|
|
||||||
tokenizers:
|
|
||||||
- name: substring
|
|
||||||
type: ngram
|
|
||||||
min_gram: 2
|
|
||||||
max_gram: 20
|
|
||||||
prefix_only: false
|
|
||||||
field_mappings:
|
|
||||||
- name: document_id
|
|
||||||
type: text
|
|
||||||
stored: true
|
|
||||||
- name: version_id
|
|
||||||
type: text
|
|
||||||
stored: true
|
|
||||||
- name: title
|
|
||||||
type: text
|
|
||||||
tokenizer: substring
|
|
||||||
stored: true
|
|
||||||
- name: text
|
|
||||||
type: text
|
|
||||||
tokenizer: substring
|
|
||||||
record: position
|
|
||||||
search_settings:
|
|
||||||
default_search_fields:
|
|
||||||
- title
|
|
||||||
- text
|
|
||||||
Reference in New Issue
Block a user