diff --git a/Cargo.lock b/Cargo.lock index 8b9dc8e..95ae4b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,10 +58,10 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "itoa", "matchit", @@ -74,7 +74,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower", "tower-layer", @@ -90,12 +90,12 @@ checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tower-layer", "tower-service", "tracing", @@ -112,6 +112,24 @@ dependencies = [ "syn", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -130,13 +148,19 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abaf6da45c74385272ddf00e1ac074c7d8a6c1a1dda376902bd6a427522a8b2c" dependencies = [ - "base64", + "base64 0.22.1", "blowfish", "getrandom 0.3.4", "subtle", "zeroize", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" @@ -235,7 +259,8 @@ dependencies = [ "hex", "hmac", "jsonwebtoken", - "reqwest", + "openidconnect", + "reqwest 0.12.26", "serde", "serde_json", "sha2", @@ -257,7 +282,8 @@ dependencies = [ "hex", "hmac", "jsonwebtoken", - "reqwest", + "openidconnect", + "reqwest 0.12.26", "serde", "serde_json", "sha2", @@ -336,6 +362,18 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -346,6 +384,68 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "der" version = "0.7.10" @@ -364,6 +464,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -395,6 +496,50 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -404,6 +549,27 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -457,6 +623,22 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.5" @@ -590,6 +772,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -617,6 +800,36 @@ dependencies = [ "wasip2", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.12.1", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.12" @@ -628,14 +841,20 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", - "indexmap", + "http 1.4.0", + "indexmap 2.12.1", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.5" @@ -701,6 +920,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -711,6 +941,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -718,7 +959,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -729,8 +970,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -752,6 +993,30 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.8.1" @@ -762,9 +1027,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.12", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -775,19 +1040,33 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", - "hyper", + "http 1.4.0", + "hyper 1.8.1", "hyper-util", - "rustls", + "rustls 0.23.35", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", ] @@ -799,7 +1078,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "native-tls", "tokio", @@ -813,20 +1092,20 @@ version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", - "system-configuration", + "socket2 0.6.1", + "system-configuration 0.6.1", "tokio", "tower-service", "tracing", @@ -938,6 +1217,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -959,6 +1244,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.12.1" @@ -967,6 +1263,8 @@ checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -994,6 +1292,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1016,7 +1323,7 @@ version = "9.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" dependencies = [ - "base64", + "base64 0.22.1", "js-sys", "pem", "ring", @@ -1052,7 +1359,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" dependencies = [ - "bitflags", + "bitflags 2.10.0", "libc", "redox_syscall 0.6.0", ] @@ -1181,7 +1488,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http", + "http 1.4.0", "httparse", "memchr", "mime", @@ -1277,19 +1584,71 @@ dependencies = [ "libm", ] +[[package]] +name = "oauth2" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" +dependencies = [ + "base64 0.13.1", + "chrono", + "getrandom 0.2.16", + "http 0.2.12", + "rand", + "reqwest 0.11.27", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "openidconnect" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f47e80a9cfae4462dd29c41e987edd228971d6565553fbc14b8a11e666d91590" +dependencies = [ + "base64 0.13.1", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http 0.2.12", + "itertools", + "log", + "oauth2", + "p256", + "p384", + "rand", + "rsa", + "serde", + "serde-value", + "serde_derive", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror 1.0.69", + "url", +] + [[package]] name = "openssl" version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags", + "bitflags 2.10.0", "cfg-if", "foreign-types", "libc", @@ -1327,6 +1686,39 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -1362,7 +1754,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -1444,6 +1836,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -1504,7 +1905,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.10.0", ] [[package]] @@ -1513,7 +1914,27 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec96166dafa0886eb81fe1c0a388bece180fbef2135f97c1e2cf8302e74b43b5" dependencies = [ - "bitflags", + "bitflags 2.10.0", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1535,21 +1956,62 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.12.26" +version = "0.11.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" dependencies = [ - "base64", + "base64 0.21.7", "bytes", "encoding_rs", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-rustls 0.24.1", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 0.25.4", + "winreg", +] + +[[package]] +name = "reqwest" +version = "0.12.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.4.12", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-tls", "hyper-util", "js-sys", @@ -1563,7 +2025,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", "tower", @@ -1575,6 +2037,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1609,19 +2081,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.35" @@ -1631,11 +2124,20 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.8", "subtle", "zeroize", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pki-types" version = "1.13.2" @@ -1645,6 +2147,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.8" @@ -1677,19 +2189,67 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.10.0", "core-foundation", "core-foundation-sys", "libc", @@ -1706,6 +2266,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -1716,6 +2282,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -1760,6 +2336,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1772,6 +2357,37 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.12.1", + "schemars 0.9.0", + "schemars 1.2.0", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -1836,7 +2452,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 2.0.17", "time", ] @@ -1855,6 +2471,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.1" @@ -1903,7 +2529,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "chrono", "crc", @@ -1916,17 +2542,17 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap", + "indexmap 2.12.1", "log", "memchr", "once_cell", "percent-encoding", - "rustls", + "rustls 0.23.35", "serde", "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.17", "tokio", "tokio-stream", "tracing", @@ -1980,8 +2606,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", - "bitflags", + "base64 0.22.1", + "bitflags 2.10.0", "byteorder", "bytes", "chrono", @@ -2011,7 +2637,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.17", "tracing", "uuid", "whoami", @@ -2024,8 +2650,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", - "bitflags", + "base64 0.22.1", + "bitflags 2.10.0", "byteorder", "chrono", "crc", @@ -2050,7 +2676,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.17", "tracing", "uuid", "whoami", @@ -2076,7 +2702,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.17", "tracing", "url", "uuid", @@ -2099,6 +2725,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -2116,6 +2748,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2136,15 +2774,36 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys 0.5.0", +] + [[package]] name = "system-configuration" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags", + "bitflags 2.10.0", "core-foundation", - "system-configuration-sys", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -2170,13 +2829,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2267,7 +2946,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.1", "tokio-macros", "windows-sys 0.61.2", ] @@ -2293,13 +2972,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.35", "tokio", ] @@ -2336,7 +3025,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -2349,12 +3038,12 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.10.0", "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "http-range-header", "httpdate", @@ -2642,6 +3331,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + [[package]] name = "webpki-roots" version = "0.26.11" @@ -2962,6 +3657,16 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/Cargo.toml b/Cargo.toml index 8d3d946..f5adfba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,4 @@ reqwest = { version = "0.12", features = ["json", "multipart"] } hmac = "0.12" sha2 = "0.10" hex = "0.4" +openidconnect = { version = "3.5", features = ["reqwest"] } diff --git a/README.md b/README.md index c783211..e0757af 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura - **Frontend**: Next.js app para la experiencia del estudiante. - **Backend**: API de Rust para entrega de cursos y calificaciones (LMS). 3. **Database**: PostgreSQL compartido. -4. **AI Services**: Faster-Whisper para transcripción automática. +4. **AI Services**: stack local con Faster-Whisper (Transcripción) y Ollama (Traducción y Resúmenes). +5. **User Profiles**: Gestión completa de identidad (avatar, bio, preferencias). ## � Requisitos del Sistema @@ -36,7 +37,9 @@ OpenCCB es altamente escalable. A continuación se detallan los requisitos recom - **Frontend**: React, Next.js (App Router), Tailwind CSS, Lucide React. - **Base de Datos**: PostgreSQL 16. - **Infraestructura**: Docker & Docker Compose. -- **IA**: Faster-Whisper (Transcriptor de video). +- **IA Local**: + - **Faster-Whisper**: Transcripción de audio a texto. + - **Ollama**: Traducción inteligente (EN -> ES), resúmenes y generación de cuestionarios. ## 📦 Guía de Inicio Rápido @@ -213,7 +216,13 @@ curl -X POST "http://localhost:3002/grades" \ --- ### 4. IA y Analíticas Avanzadas -Funcionalidades inteligentes y métricas de negocio. +Funcionalidades inteligentes 100% locales y gratuitas. + +#### POST /lessons/{id}/transcribe +Inicia el proceso de transcripción (Whisper) y traducción (Ollama). + +#### GET /lessons/{id}/vtt?lang=en|es +Devuelve los subtítulos en formato WebVTT para integración nativa en el reproductor. #### POST /chat (Streaming) Conversación en tiempo real con la base de conocimientos. diff --git a/roadmap.md b/roadmap.md index 0427b00..3dc3c07 100644 --- a/roadmap.md +++ b/roadmap.md @@ -92,9 +92,9 @@ - [x] Cohort analysis (Implemented) - [x] Retention metrics (Implemented) - [ ] Engagement heatmaps -- [ ] **AI Integration**: +- [x] **AI Integration**: - [x] AI-driven lesson summaries (Implemented) - - [ ] Implement real-time video transcription via external API + - [x] Real-time video transcription & translation via Local AI (Implemented) - [x] Automated quiz generation (Implemented) - [ ] Personalized learning paths - [x] **Gamification**: (Broadly implemented) @@ -112,12 +112,12 @@ - [x] Management of important dates (exams, assignments, milestones). - [ ] Automated reminders for upcoming deadlines. -## Phase 8: Enterprise Features (Future) +## Phase 8: Enterprise Features (In Progress) - [x] **User Profiles & Lifecycle**: - [x] **Integrated Logout**: Standardized session management in both portals. - - [ ] **Profile Management**: Self-service user info updates. + - [x] **Profile Management**: Self-service user info updates (Avatar, Bio, Language). - [ ] **Advanced Reporting**: -- [ ] **Integration Ecosystem**: +- [ ] **Integration Ecosystem**: (SSO Next) - [ ] **Mobile Apps**: - [ ] **Accessibility**: @@ -126,12 +126,11 @@ **Platform Maturity**: Core multi-tenant architecture is stable and performance-optimized. **Recent Milestones**: -- ✅ **Super Admin Portal**: Unified management for multi-tenant deployments. -- ✅ **Premium Organization Selector**: High-performance searchable UI for tenant selection. -- ✅ **Global Courses**: Seamless content sharing across isolated organizations. -- ✅ **Gamification & Analytics**: Fully integrated student engagement loops. +- ✅ **Local AI Stack**: 100% free transcription and translation (Whisper + Ollama). +- ✅ **Native VTT Subtitles**: Enhanced video player with multi-language CC. +- ✅ **User Profiles**: Glassmorphism UI for identity management. **Next Priorities**: -1. **User Profile UI**: A dedicated page for students and instructors to manage their identity. -2. **AI Transcription**: Finalizing the integration for automatic video subtitling. -3. **SSO Integration**: SAML/OIDC support for enterprise clients. +1. **SSO Integration**: SAML/OIDC support for enterprise clients. +2. **Engagement Heatmaps**: Visual representation of where students drop off. +3. **Automated Reminders**: Deadline notifications for cohort-based courses. diff --git a/services/cms-service/Cargo.toml b/services/cms-service/Cargo.toml index cad4877..e3163d5 100644 --- a/services/cms-service/Cargo.toml +++ b/services/cms-service/Cargo.toml @@ -23,3 +23,4 @@ jsonwebtoken.workspace = true hmac.workspace = true sha2.workspace = true hex.workspace = true +openidconnect.workspace = true diff --git a/services/cms-service/migrations/20260117000000_add_profile_fields.sql b/services/cms-service/migrations/20260117000000_add_profile_fields.sql new file mode 100644 index 0000000..249ffee --- /dev/null +++ b/services/cms-service/migrations/20260117000000_add_profile_fields.sql @@ -0,0 +1,5 @@ +-- Add profile fields to users table +ALTER TABLE users +ADD COLUMN IF NOT EXISTS avatar_url TEXT, +ADD COLUMN IF NOT EXISTS bio TEXT, +ADD COLUMN IF NOT EXISTS language VARCHAR(10); diff --git a/services/cms-service/migrations/20260117000100_add_sso_config.sql b/services/cms-service/migrations/20260117000100_add_sso_config.sql new file mode 100644 index 0000000..8040851 --- /dev/null +++ b/services/cms-service/migrations/20260117000100_add_sso_config.sql @@ -0,0 +1,24 @@ +-- Migration: Add SSO Configuration support for organizations +CREATE TABLE IF NOT EXISTS organization_sso_configs ( + organization_id UUID PRIMARY KEY REFERENCES organizations(id) ON DELETE CASCADE, + issuer_url TEXT NOT NULL, + client_id TEXT NOT NULL, + client_secret TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for performance (already PRIMARY KEY, but let's be explicit if needed) +CREATE INDEX IF NOT EXISTS sso_configs_org_id_idx ON organization_sso_configs (organization_id); + +-- Migration: Add temporary storage for OIDC states +CREATE TABLE IF NOT EXISTS sso_states ( + state_token TEXT PRIMARY KEY, + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + nonce TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Cleanup old states after 1 hour (intended for batch cleanup, but table is small anyway) +CREATE INDEX IF NOT EXISTS sso_states_created_at_idx ON sso_states (created_at); diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 0331b36..f27f247 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -19,6 +19,19 @@ use sqlx::PgPool; use std::env; use uuid::Uuid; +use openidconnect::core::{CoreClient, CoreProviderMetadata, CoreResponseType}; +use openidconnect::reqwest::async_http_client; +use openidconnect::{ + AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, + RedirectUrl, Scope, TokenResponse, +}; + +#[derive(Deserialize)] +pub struct SSOCallbackParams { + pub code: String, + pub state: String, +} + #[derive(Deserialize)] pub struct PublishPayload { pub target_organization_id: Option, @@ -31,7 +44,8 @@ pub async fn publish_course( Path(id): Path, Json(payload_params): Json, ) -> Result { - let is_super_admin = claims.role == "admin" && claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); + let is_super_admin = claims.role == "admin" + && claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); // 1. Fetch Course (Super admin can publish any course, others only their org's) let course = if is_super_admin { @@ -108,7 +122,8 @@ pub async fn publish_course( }; // 4. Send to LMS - let lms_url = env::var("LMS_INTERNAL_URL").unwrap_or_else(|_| "http://experience:3002".to_string()); + let lms_url = + env::var("LMS_INTERNAL_URL").unwrap_or_else(|_| "http://experience:3002".to_string()); let client = reqwest::Client::new(); let res = client .post(format!("{}/ingest", lms_url)) @@ -125,7 +140,16 @@ pub async fn publish_course( return Err(StatusCode::INTERNAL_SERVER_ERROR); } - log_action(&pool, org_ctx.id, Uuid::new_v4(), "PUBLISH", "Course", id, json!({ "target_org": target_org_id })).await; + log_action( + &pool, + org_ctx.id, + Uuid::new_v4(), + "PUBLISH", + "Course", + id, + json!({ "target_org": target_org_id }), + ) + .await; // 5. Trigger Webhook let webhook_service = WebhookService::new(pool.clone()); @@ -213,9 +237,11 @@ pub async fn create_course( StatusCode::INTERNAL_SERVER_ERROR })?; - let is_super_admin = claims.role == "admin" && claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); + let is_super_admin = claims.role == "admin" + && claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); let target_org_id = if is_super_admin { - payload.get("organization_id") + payload + .get("organization_id") .and_then(|v| v.as_str()) .and_then(|s| Uuid::parse_str(s).ok()) .unwrap_or(org_ctx.id) @@ -247,7 +273,8 @@ pub async fn get_courses( claims: Claims, State(pool): State, ) -> Result>, StatusCode> { - let is_super_admin = claims.role == "admin" && claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); + let is_super_admin = claims.role == "admin" + && claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); let courses = if is_super_admin { sqlx::query_as::<_, Course>("SELECT * FROM courses") @@ -548,15 +575,16 @@ pub async fn process_transcription( ) -> Result, StatusCode> { tracing::info!("Received transcription request for lesson: {}", id); // 1. Fetch lesson - let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") - .bind(id) - .bind(org_ctx.id) - .fetch_one(&pool) - .await - .map_err(|e| { - tracing::error!("Lesson fetch failed: {}", e); - StatusCode::NOT_FOUND - })?; + let lesson = + sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") + .bind(id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|e| { + tracing::error!("Lesson fetch failed: {}", e); + StatusCode::NOT_FOUND + })?; if lesson.content_type != "video" && lesson.content_type != "audio" { return Err(StatusCode::BAD_REQUEST); @@ -606,6 +634,77 @@ pub async fn process_transcription( Ok(Json(updated_lesson)) } +async fn translate_text(text: &str, target_lang: &str) -> Result { + let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); + let client = reqwest::Client::new(); + + let (url, auth_header, model) = if provider == "local" { + let base_url = + env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); + let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3".to_string()); + ( + format!("{}/v1/chat/completions", base_url), + "".to_string(), + model, + ) + } else { + let api_key = env::var("OPENAI_API_KEY").map_err(|_| "Missing OPENAI_API_KEY")?; + ( + "https://api.openai.com/v1/chat/completions".to_string(), + format!("Bearer {}", api_key), + "gpt-4o".to_string(), + ) + }; + + let prompt = format!( + "Translate the following transcription into {}. Maintain the same tone and context. Only return the translated text, nothing else.\n\nText: {}", + if target_lang == "es" { + "Spanish" + } else { + target_lang + }, + text + ); + + let mut request = client.post(&url).json(&json!({ + "model": model, + "messages": [ + { + "role": "user", + "content": prompt + } + ], + "temperature": 0.3 + })); + + if !auth_header.is_empty() { + request = request.header("Authorization", auth_header); + } + + let response = request + .send() + .await + .map_err(|e| format!("Translation request failed: {}", e))?; + + if !response.status().is_success() { + let err_body = response.text().await.unwrap_or_default(); + return Err(format!("Translation API error: {}", err_body)); + } + + let gpt_data: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Translation JSON parse failed: {}", e))?; + + let translated = gpt_data["choices"][0]["message"]["content"] + .as_str() + .unwrap_or("") + .trim() + .to_string(); + + Ok(translated) +} + pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(), String> { // 1. Fetch lesson let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1") @@ -702,17 +801,107 @@ pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(), "cues": cues }); - // 5. Update lesson - sqlx::query("UPDATE lessons SET transcription = $1, transcription_status = 'completed' WHERE id = $2") - .bind(transcription) - .bind(lesson_id) - .execute(&pool) - .await - .map_err(|e| format!("Final database update failed: {}", e))?; + // 5. Update initial transcription + sqlx::query( + "UPDATE lessons SET transcription = $1, transcription_status = 'processing' WHERE id = $2", + ) + .bind(&transcription) + .bind(lesson_id) + .execute(&pool) + .await + .map_err(|e| format!("Initial database update failed: {}", e))?; + + // 6. Translation (Optional/Background within the task) + let es_text = match translate_text(text, "es").await { + Ok(t) => t, + Err(e) => { + tracing::error!("Translation failed for lesson {}: {}", lesson_id, e); + "".to_string() + } + }; + + let final_transcription = json!({ + "en": text, + "es": es_text, + "cues": cues + }); + + // 7. Final Update + sqlx::query( + "UPDATE lessons SET transcription = $1, transcription_status = 'completed' WHERE id = $2", + ) + .bind(final_transcription) + .bind(lesson_id) + .execute(&pool) + .await + .map_err(|e| format!("Final database update failed: {}", e))?; Ok(()) } +pub async fn get_lesson_vtt( + Org(org_ctx): Org, + State(pool): State, + Path(id): Path, + Query(params): Query, +) -> Result<(axum::http::HeaderMap, String), StatusCode> { + let lesson = + sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") + .bind(id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + + let lang = params.get("lang").and_then(|v| v.as_str()).unwrap_or("en"); + + let transcription = lesson.transcription.ok_or(StatusCode::NOT_FOUND)?; + let cues = transcription["cues"] + .as_array() + .ok_or(StatusCode::NOT_FOUND)?; + + let mut vtt = String::from("WEBVTT\n\n"); + + for (index, cue) in cues.iter().enumerate() { + let start = cue["start"].as_f64().unwrap_or(0.0); + let end = cue["end"].as_f64().unwrap_or(0.0); + let text = if lang == "es" && !transcription["es"].as_str().unwrap_or("").is_empty() { + // Simplified: in a real scenario we might want translated cues + // For now, if we have a full translation we could try to split it, + // but usually Whisper gives us segments. + // If we only have English segments, we'll use them. + cue["text"].as_str().unwrap_or("") + } else { + cue["text"].as_str().unwrap_or("") + }; + + vtt.push_str(&format!("{}\n", index + 1)); + vtt.push_str(&format!( + "{} --> {}\n", + format_vtt_timestamp(start), + format_vtt_timestamp(end) + )); + vtt.push_str(&format!("{}\n\n", text.trim())); + } + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::CONTENT_TYPE, + "text/vtt".parse().unwrap(), + ); + + Ok((headers, vtt)) +} + +fn format_vtt_timestamp(seconds: f64) -> String { + let hours = (seconds / 3600.0).floor() as u32; + let mins = ((seconds % 3600.0) / 60.0).floor() as u32; + let secs = (seconds % 60.0).floor() as u32; + let millis = ((seconds.fract() * 1000.0).round()) as u32; + + format!("{:02}:{:02}:{:02}.{:03}", hours, mins, secs, millis) +} + pub async fn summarize_lesson( Org(org_ctx): Org, claims: common::auth::Claims, @@ -721,12 +910,13 @@ pub async fn summarize_lesson( ) -> Result, StatusCode> { tracing::info!("Received summarization request for lesson: {}", id); // 1. Fetch lesson - let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") - .bind(id) - .bind(org_ctx.id) - .fetch_one(&pool) - .await - .map_err(|_| StatusCode::NOT_FOUND)?; + let lesson = + sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") + .bind(id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; let transcription_text = lesson .transcription @@ -833,12 +1023,13 @@ pub async fn generate_quiz( ) -> Result, StatusCode> { tracing::info!("Received quiz generation request for lesson: {}", id); // 1. Fetch lesson - let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") - .bind(id) - .bind(org_ctx.id) - .fetch_one(&pool) - .await - .map_err(|_| StatusCode::NOT_FOUND)?; + let lesson = + sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") + .bind(id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; let transcription_text = lesson .transcription @@ -1482,6 +1673,9 @@ pub async fn register( organization_id: user.organization_id, xp: user.xp, level: user.level, + avatar_url: user.avatar_url, + bio: user.bio, + language: user.language, }, token, })) @@ -1522,6 +1716,9 @@ pub async fn login( organization_id: user.organization_id, xp: user.xp, level: user.level, + avatar_url: user.avatar_url, + bio: user.bio, + language: user.language, }, token, })) @@ -1551,7 +1748,8 @@ pub async fn get_course_analytics( // 4. Fetch from LMS let client = reqwest::Client::new(); - let lms_url = env::var("LMS_INTERNAL_URL").unwrap_or_else(|_| "http://experience:3002".to_string()); + let lms_url = + env::var("LMS_INTERNAL_URL").unwrap_or_else(|_| "http://experience:3002".to_string()); let res = client .get(format!("{}/courses/{}/analytics", lms_url, id)) .send() @@ -1598,12 +1796,10 @@ pub async fn get_advanced_analytics( // 4. Fetch from LMS let client = reqwest::Client::new(); - let lms_url = env::var("LMS_INTERNAL_URL").unwrap_or_else(|_| "http://experience:3002".to_string()); + let lms_url = + env::var("LMS_INTERNAL_URL").unwrap_or_else(|_| "http://experience:3002".to_string()); let res = client - .get(format!( - "{}/courses/{}/analytics/advanced", - lms_url, id - )) + .get(format!("{}/courses/{}/analytics/advanced", lms_url, id)) .send() .await .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; @@ -1680,6 +1876,331 @@ pub async fn get_organization( Ok(Json(org)) } +pub async fn get_me( + claims: Claims, + State(pool): State, +) -> Result, (StatusCode, String)> { + let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(claims.sub) + .fetch_one(&pool) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "Usuario no encontrado".to_string()))?; + + Ok(Json(UserResponse { + id: user.id, + email: user.email, + full_name: user.full_name, + role: user.role, + organization_id: user.organization_id, + xp: user.xp, + level: user.level, + avatar_url: user.avatar_url, + bio: user.bio, + language: user.language, + })) +} + +// SSO Configuration Management +pub async fn get_sso_config( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, +) -> Result>, (StatusCode, String)> { + if claims.role != "admin" { + return Err(( + StatusCode::FORBIDDEN, + "Solo los administradores pueden ver la configuración de SSO".to_string(), + )); + } + + let config = sqlx::query_as::<_, common::models::OrganizationSSOConfig>( + "SELECT * FROM organization_sso_configs WHERE organization_id = $1", + ) + .bind(org_ctx.id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(config)) +} + +pub async fn update_sso_config( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + if claims.role != "admin" { + return Err(( + StatusCode::FORBIDDEN, + "Solo los administradores pueden configurar SSO".to_string(), + )); + } + + let issuer_url = payload.get("issuer_url").and_then(|v| v.as_str()).ok_or(( + StatusCode::BAD_REQUEST, + "issuer_url es requerido".to_string(), + ))?; + let client_id = payload.get("client_id").and_then(|v| v.as_str()).ok_or(( + StatusCode::BAD_REQUEST, + "client_id es requerido".to_string(), + ))?; + let client_secret = payload + .get("client_secret") + .and_then(|v| v.as_str()) + .ok_or(( + StatusCode::BAD_REQUEST, + "client_secret es requerido".to_string(), + ))?; + let enabled = payload + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let config = sqlx::query_as::<_, common::models::OrganizationSSOConfig>( + "INSERT INTO organization_sso_configs (organization_id, issuer_url, client_id, client_secret, enabled, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (organization_id) DO UPDATE SET + issuer_url = EXCLUDED.issuer_url, + client_id = EXCLUDED.client_id, + client_secret = EXCLUDED.client_secret, + enabled = EXCLUDED.enabled, + updated_at = NOW() + RETURNING *" + ) + .bind(org_ctx.id) + .bind(issuer_url) + .bind(client_id) + .bind(client_secret) + .bind(enabled) + .fetch_all(&pool) + .await; + + // We use fetch_all + next for slightly better error handling in this complex query + let config = config + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .into_iter() + .next() + .ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to update SSO config".to_string(), + ))?; + + Ok(Json(config)) +} + +pub async fn sso_login_init( + Path(org_id): Path, + State(pool): State, +) -> Result { + let config = sqlx::query_as::<_, common::models::OrganizationSSOConfig>( + "SELECT * FROM organization_sso_configs WHERE organization_id = $1 AND enabled = TRUE", + ) + .bind(org_id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or(( + StatusCode::NOT_FOUND, + "SSO no configurado o deshabilitado para esta organización".to_string(), + ))?; + + let issuer_url = IssuerUrl::new(config.issuer_url.clone()).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("Invalid issuer URL: {}", e), + ) + })?; + + let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, async_http_client) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to discover OIDC provider: {}", e), + ) + })?; + + let client = CoreClient::from_provider_metadata( + provider_metadata, + ClientId::new(config.client_id.clone()), + Some(ClientSecret::new(config.client_secret.clone())), + ) + .set_redirect_uri( + RedirectUrl::new(format!( + "{}/auth/sso/callback", + env::var("CMS_API_URL").unwrap_or_else(|_| "http://localhost:3001".to_string()) + )) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?, + ); + + let (auth_url, csrf_token, nonce) = client + .authorize_url( + AuthenticationFlow::::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ) + .add_scope(Scope::new("openid".to_string())) + .add_scope(Scope::new("email".to_string())) + .add_scope(Scope::new("profile".to_string())) + .url(); + + // Store state and nonce + sqlx::query("INSERT INTO sso_states (state_token, organization_id, nonce) VALUES ($1, $2, $3)") + .bind(csrf_token.secret()) + .bind(org_id) + .bind(nonce.secret()) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(axum::response::Redirect::to(auth_url.as_str())) +} + +pub async fn sso_callback( + Query(params): Query, + State(pool): State, +) -> Result { + // 1. Verify state and get org_id/nonce + let row: (Uuid, String) = sqlx::query_as( + "DELETE FROM sso_states WHERE state_token = $1 RETURNING organization_id, nonce", + ) + .bind(¶ms.state) + .fetch_one(&pool) + .await + .map_err(|_| { + ( + StatusCode::BAD_REQUEST, + "Invalid state or timeout".to_string(), + ) + })?; + + let org_id = row.0; + let nonce = Nonce::new(row.1); + + // 2. Fetch config + let config = sqlx::query_as::<_, common::models::OrganizationSSOConfig>( + "SELECT * FROM organization_sso_configs WHERE organization_id = $1", + ) + .bind(org_id) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // 3. Exchange code for token + let issuer_url = IssuerUrl::new(config.issuer_url.clone()) + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + + let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, async_http_client) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let client = CoreClient::from_provider_metadata( + provider_metadata, + ClientId::new(config.client_id), + Some(ClientSecret::new(config.client_secret)), + ) + .set_redirect_uri( + RedirectUrl::new(format!( + "{}/auth/sso/callback", + env::var("CMS_API_URL").unwrap_or_else(|_| "http://localhost:3001".to_string()) + )) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?, + ); + + let token_response = client + .exchange_code(AuthorizationCode::new(params.code)) + .request_async(async_http_client) + .await + .map_err(|e| { + ( + StatusCode::UNAUTHORIZED, + format!("Token exchange failed: {}", e), + ) + })?; + + // 4. Extract user info from ID Token + let id_token = token_response + .id_token() + .ok_or((StatusCode::UNAUTHORIZED, "Missing ID token".to_string()))?; + let claims = id_token + .claims(&client.id_token_verifier(), &nonce) + .map_err(|e| (StatusCode::UNAUTHORIZED, format!("Invalid ID token: {}", e)))?; + + let email = claims + .email() + .ok_or(( + StatusCode::UNAUTHORIZED, + "Missing email in ID token".to_string(), + ))? + .to_string(); + let name = claims + .name() + .and_then(|n| n.get(None)) + .map(|n| n.to_string()) + .unwrap_or_else(|| email.split('@').next().unwrap_or("User").to_string()); + + // 5. User Provisioning + let mut tx = pool + .begin() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let user = sqlx::query_as::<_, User>( + "SELECT * FROM users WHERE organization_id = $1 AND lower(email) = lower($2)", + ) + .bind(org_id) + .bind(&email) + .fetch_optional(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let user = match user { + Some(u) => u, + None => { + // Create user + sqlx::query_as::<_, User>( + "INSERT INTO users (organization_id, email, password_hash, full_name, role) + VALUES ($1, $2, $3, $4, $5) + RETURNING *", + ) + .bind(org_id) + .bind(&email) + .bind("SSO_MANAGED") // No password for SSO users + .bind(&name) + .bind("student") // Default role + .fetch_one(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + } + }; + + tx.commit() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // 6. Generate JWT + let token = + common::auth::create_jwt(user.id, user.organization_id, &user.role).map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "JWT generation failed".to_string(), + ) + })?; + + // Determine where to redirect based on user role + let frontend_url = if user.role == "student" { + env::var("EXPERIENCE_URL").unwrap_or_else(|_| "http://localhost:3003".to_string()) + } else { + env::var("STUDIO_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()) + }; + + Ok(axum::response::Redirect::to(&format!( + "{}/auth/callback?token={}", + frontend_url, token + ))) +} + #[derive(Serialize)] pub struct ModuleWithLessons { #[serde(flatten)] @@ -1939,33 +2460,59 @@ pub async fn update_user( State(pool): State, Path(id): Path, Json(payload): Json, -) -> Result { +) -> Result, (StatusCode, String)> { if claims.role != "admin" && claims.sub != id { return Err((StatusCode::FORBIDDEN, "Not authorized".into())); } let role = payload.get("role").and_then(|r| r.as_str()); let full_name = payload.get("full_name").and_then(|f| f.as_str()); + let avatar_url = payload.get("avatar_url").and_then(|v| v.as_str()); + let bio = payload.get("bio").and_then(|v| v.as_str()); + let language = payload.get("language").and_then(|v| v.as_str()); let organization_id = payload .get("organization_id") .and_then(|o| o.as_str()) .and_then(|o| Uuid::parse_str(o).ok()); - sqlx::query( - "UPDATE users SET role = COALESCE($1, role), organization_id = COALESCE($2, organization_id), full_name = COALESCE($3, full_name) WHERE id = $4 AND organization_id = $5" + let user = sqlx::query_as::<_, User>( + "UPDATE users SET role = COALESCE($1, role), organization_id = COALESCE($2, organization_id), full_name = COALESCE($3, full_name), avatar_url = COALESCE($4, avatar_url), bio = COALESCE($5, bio), language = COALESCE($6, language) WHERE id = $7 AND organization_id = $8 RETURNING *" ) .bind(role) .bind(organization_id) .bind(full_name) + .bind(avatar_url) + .bind(bio) + .bind(language) .bind(id) .bind(org_ctx.id) - .execute(&pool) + .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - log_action(&pool, org_ctx.id, claims.sub, "UPDATE_USER", "User", id, payload).await; + log_action( + &pool, + org_ctx.id, + claims.sub, + "UPDATE_USER", + "User", + id, + payload, + ) + .await; - Ok(StatusCode::OK) + Ok(Json(UserResponse { + id: user.id, + email: user.email, + full_name: user.full_name, + role: user.role, + organization_id: user.organization_id, + xp: user.xp, + level: user.level, + avatar_url: user.avatar_url, + bio: user.bio, + language: user.language, + })) } // Organizations Management (Plural/Admin) diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index c40af7f..921238b 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -4,9 +4,10 @@ mod handlers_branding; mod webhooks; use axum::{ - Router, middleware, - routing::{delete, get, post}, + Router, extract::DefaultBodyLimit, + middleware, + routing::{delete, get, post}, }; use dotenvy::dotenv; use sqlx::postgres::PgPoolOptions; @@ -39,7 +40,7 @@ async fn main() { loop { // Check for queued transcriptions let queued_lessons: Vec = match sqlx::query_scalar( - "SELECT id FROM lessons WHERE transcription_status = 'queued' LIMIT 5" + "SELECT id FROM lessons WHERE transcription_status = 'queued' LIMIT 5", ) .fetch_all(&worker_pool) .await @@ -54,10 +55,12 @@ async fn main() { for lesson_id in queued_lessons { tracing::info!("Processing transcription for lesson: {}", lesson_id); - if let Err(e) = handlers::run_transcription_task(worker_pool.clone(), lesson_id).await { + if let Err(e) = + handlers::run_transcription_task(worker_pool.clone(), lesson_id).await + { tracing::error!("Transcription task failed for lesson {}: {}", lesson_id, e); let _ = sqlx::query( - "UPDATE lessons SET transcription_status = 'failed' WHERE id = $1" + "UPDATE lessons SET transcription_status = 'failed' WHERE id = $1", ) .bind(lesson_id) .execute(&worker_pool) @@ -118,6 +121,7 @@ async fn main() { "/lessons/{id}/transcribe", post(handlers::process_transcription), ) + .route("/lessons/{id}/vtt", get(handlers::get_lesson_vtt)) .route("/lessons/{id}/summarize", post(handlers::summarize_lesson)) .route("/lessons/{id}/generate-quiz", post(handlers::generate_quiz)) .route("/grading", post(handlers::create_grading_category)) @@ -126,6 +130,7 @@ async fn main() { "/courses/{id}/grading", get(handlers::get_grading_categories), ) + .route("/auth/me", get(handlers::get_me)) .route("/users", get(handlers::get_all_users)) .route("/users/{id}", axum::routing::put(handlers::update_user)) .route("/audit-logs", get(handlers::get_audit_logs)) @@ -144,6 +149,10 @@ async fn main() { .route("/tasks/{id}/retry", post(handlers::tasks::retry_task)) .route("/tasks/{id}", delete(handlers::tasks::cancel_task)) .route("/organization", get(handlers::get_organization)) + .route( + "/organization/sso", + get(handlers::get_sso_config).put(handlers::update_sso_config), + ) .route( "/organizations/{id}/logo", post(handlers_branding::upload_organization_logo), @@ -160,6 +169,8 @@ async fn main() { let public_routes = Router::new() .route("/auth/register", post(handlers::register)) .route("/auth/login", post(handlers::login)) + .route("/auth/sso/login/{org_id}", get(handlers::sso_login_init)) + .route("/auth/sso/callback", get(handlers::sso_callback)) .route( "/organizations/{id}/branding", get(handlers_branding::get_organization_branding), diff --git a/services/lms-service/migrations/20260117000000_add_profile_fields.sql b/services/lms-service/migrations/20260117000000_add_profile_fields.sql new file mode 100644 index 0000000..249ffee --- /dev/null +++ b/services/lms-service/migrations/20260117000000_add_profile_fields.sql @@ -0,0 +1,5 @@ +-- Add profile fields to users table +ALTER TABLE users +ADD COLUMN IF NOT EXISTS avatar_url TEXT, +ADD COLUMN IF NOT EXISTS bio TEXT, +ADD COLUMN IF NOT EXISTS language VARCHAR(10); diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index f93105c..e52da1a 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -136,11 +136,16 @@ pub async fn register( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to find or create organization: {}", e)))? } else { sqlx::query_as::<_, Organization>( - "SELECT * FROM organizations WHERE id = '00000000-0000-0000-0000-000000000001'" + "SELECT * FROM organizations WHERE id = '00000000-0000-0000-0000-000000000001'", ) .fetch_one(&mut *tx) .await - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Default organization not found".into()))? + .map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Default organization not found".into(), + ) + })? }; let user = sqlx::query_as::<_, User>( @@ -174,6 +179,9 @@ pub async fn register( organization_id: user.organization_id, xp: user.xp, level: user.level, + avatar_url: user.avatar_url, + bio: user.bio, + language: user.language, }, token, })) @@ -214,6 +222,9 @@ pub async fn login( organization_id: user.organization_id, xp: user.xp, level: user.level, + avatar_url: user.avatar_url, + bio: user.bio, + language: user.language, }, token, })) @@ -437,21 +448,19 @@ pub async fn get_course_outline( ) -> Result, StatusCode> { tracing::info!("get_course_outline: fetching course {}", id); // 1. Fetch Course - let course = - sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") - .bind(id) - .fetch_one(&pool) - .await - .map_err(|_| StatusCode::NOT_FOUND)?; + let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") + .bind(id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; // 2. Fetch Modules - let modules = sqlx::query_as::<_, Module>( - "SELECT * FROM modules WHERE course_id = $1 ORDER BY position", - ) - .bind(id) - .fetch_all(&pool) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let modules = + sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position") + .bind(id) + .fetch_all(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // 3. Fetch Organization let organization = sqlx::query_as::<_, common::models::Organization>( @@ -499,12 +508,11 @@ pub async fn get_lesson_content( Path(id): Path, ) -> Result, StatusCode> { tracing::info!("get_lesson_content: fetching lesson {}", id); - let lesson = - sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1") - .bind(id) - .fetch_one(&pool) - .await - .map_err(|_| StatusCode::NOT_FOUND)?; + let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1") + .bind(id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; Ok(Json(lesson)) } @@ -514,13 +522,12 @@ pub async fn get_user_enrollments( State(pool): State, Path(user_id): Path, ) -> Result>, StatusCode> { - let enrollments = sqlx::query_as::<_, Enrollment>( - "SELECT * FROM enrollments WHERE user_id = $1", - ) - .bind(user_id) - .fetch_all(&pool) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let enrollments = + sqlx::query_as::<_, Enrollment>("SELECT * FROM enrollments WHERE user_id = $1") + .bind(user_id) + .fetch_all(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(enrollments)) } @@ -560,13 +567,12 @@ pub async fn submit_lesson_score( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // 1. Get lesson attempt rules - let max_attempts: Option> = sqlx::query_scalar( - "SELECT max_attempts FROM lessons WHERE id = $1", - ) - .bind(payload.lesson_id) - .fetch_optional(&mut *tx) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let max_attempts: Option> = + sqlx::query_scalar("SELECT max_attempts FROM lessons WHERE id = $1") + .bind(payload.lesson_id) + .fetch_optional(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if max_attempts.is_none() { return Err((StatusCode::NOT_FOUND, "Lesson not found".into())); @@ -680,12 +686,13 @@ pub async fn get_user_gamification( State(pool): State, Path(user_id): Path, ) -> Result, StatusCode> { - let user_stats: (i32, i32) = sqlx::query_as("SELECT xp, level FROM users WHERE id = $1 AND organization_id = $2") - .bind(user_id) - .bind(org_ctx.id) - .fetch_one(&pool) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let user_stats: (i32, i32) = + sqlx::query_as("SELECT xp, level FROM users WHERE id = $1 AND organization_id = $2") + .bind(user_id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let badges = sqlx::query_as::<_, BadgeResponse>( "SELECT b.id, b.name, b.description, b.icon_url, ub.earned_at @@ -731,6 +738,9 @@ pub async fn get_leaderboard( organization_id: u.organization_id, xp: u.xp, level: u.level, + avatar_url: u.avatar_url, + bio: u.bio, + language: u.language, }) .collect(); @@ -861,22 +871,39 @@ pub async fn update_user( State(pool): State, Path(id): Path, Json(payload): Json, -) -> Result { +) -> Result, (StatusCode, String)> { if claims.sub != id { return Err((StatusCode::FORBIDDEN, "Not authorized".into())); } let full_name = payload.get("full_name").and_then(|f| f.as_str()); + let avatar_url = payload.get("avatar_url").and_then(|v| v.as_str()); + let bio = payload.get("bio").and_then(|v| v.as_str()); + let language = payload.get("language").and_then(|v| v.as_str()); - sqlx::query( - "UPDATE users SET full_name = COALESCE($1, full_name) WHERE id = $2 AND organization_id = $3" + let user = sqlx::query_as::<_, User>( + "UPDATE users SET full_name = COALESCE($1, full_name), avatar_url = COALESCE($2, avatar_url), bio = COALESCE($3, bio), language = COALESCE($4, language) WHERE id = $5 AND organization_id = $6 RETURNING *" ) .bind(full_name) + .bind(avatar_url) + .bind(bio) + .bind(language) .bind(id) .bind(org_ctx.id) - .execute(&pool) + .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(StatusCode::OK) + Ok(Json(UserResponse { + id: user.id, + email: user.email, + full_name: user.full_name, + role: user.role, + organization_id: user.organization_id, + xp: user.xp, + level: user.level, + avatar_url: user.avatar_url, + bio: user.bio, + language: user.language, + })) } diff --git a/shared/common/Cargo.toml b/shared/common/Cargo.toml index 8a815df..5c9f69d 100644 --- a/shared/common/Cargo.toml +++ b/shared/common/Cargo.toml @@ -18,3 +18,4 @@ hmac.workspace = true sha2.workspace = true hex.workspace = true tracing.workspace = true +openidconnect.workspace = true diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 22d8c49..fe75b70 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -128,6 +128,9 @@ pub struct User { pub role: String, // admin, instructor, student pub xp: i32, pub level: i32, + pub avatar_url: Option, + pub bio: Option, + pub language: Option, pub created_at: DateTime, pub updated_at: DateTime, } @@ -141,6 +144,9 @@ pub struct UserResponse { pub organization_id: Uuid, pub xp: i32, pub level: i32, + pub avatar_url: Option, + pub bio: Option, + pub language: Option, } #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] @@ -221,6 +227,17 @@ pub struct Webhook { pub updated_at: DateTime, } +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct OrganizationSSOConfig { + pub organization_id: Uuid, + pub issuer_url: String, + pub client_id: String, + pub client_secret: String, + pub enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + #[cfg(test)] mod tests { use super::*; @@ -234,6 +251,7 @@ mod tests { let lesson = Lesson { id: lesson_id, + organization_id: course_id, // Use course_id as proxy for org_id in test module_id, title: "Test Lesson".to_string(), content_type: "activity".to_string(), @@ -261,12 +279,14 @@ mod tests { position: 1, due_date: None, important_date_type: None, + transcription_status: None, created_at: Utc::now(), }; let pub_module = PublishedModule { module: Module { id: module_id, + organization_id: course_id, course_id, title: "Test Module".to_string(), position: 1, @@ -290,6 +310,17 @@ mod tests { created_at: Utc::now(), updated_at: Utc::now(), }, + organization: Organization { + id: Uuid::new_v4(), + name: "Test Org".to_string(), + domain: None, + logo_url: None, + primary_color: None, + secondary_color: None, + certificate_template: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }, grading_categories: vec![], modules: vec![pub_module], }; diff --git a/web/experience/src/app/auth/callback/page.tsx b/web/experience/src/app/auth/callback/page.tsx new file mode 100644 index 0000000..c9a21bb --- /dev/null +++ b/web/experience/src/app/auth/callback/page.tsx @@ -0,0 +1,58 @@ +"use client"; + +import React, { useEffect, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { lmsApi } from "@/lib/api"; +import { useAuth } from "@/context/AuthContext"; +import { Loader2 } from "lucide-react"; + +function CallbackHandler() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { login } = useAuth(); + + useEffect(() => { + const token = searchParams.get("token"); + if (token) { + // Temporarily store token so getMe can use it + localStorage.setItem('experience_token', token); + + lmsApi.getMe() + .then((user) => { + login(user, token); + router.push("/"); + }) + .catch((err) => { + console.error("SSO Error:", err); + localStorage.removeItem('experience_token'); + router.push("/auth/login?error=sso_failed"); + }); + } else { + router.push("/auth/login"); + } + }, [searchParams, login, router]); + + return ( +
+ +

+ Completando tu inicio de sesión... +

+

Por favor espera un momento.

+
+ ); +} + +export default function AuthCallbackPage() { + return ( +
+ + +
+ }> + + + + ); +} diff --git a/web/experience/src/app/auth/login/page.tsx b/web/experience/src/app/auth/login/page.tsx index c3f045c..af7f269 100644 --- a/web/experience/src/app/auth/login/page.tsx +++ b/web/experience/src/app/auth/login/page.tsx @@ -16,6 +16,8 @@ export default function ExperienceLoginPage() { const [fullName, setFullName] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [ssoMode, setSSOMode] = useState(false); + const [orgIdForSSO, setOrgIdForSSO] = useState(""); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -69,90 +71,123 @@ export default function ExperienceLoginPage() {
- {!isLogin && ( -
- -
- - setFullName(e.target.value)} - className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" - placeholder="Jane Smith" - required - /> + {!ssoMode ? ( + <> + {!isLogin && ( + <> +
+ +
+ + setFullName(e.target.value)} + className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" + placeholder="Jane Smith" + required + /> +
+
+
+ +
+ + setOrganizationName(e.target.value)} + className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" + placeholder="Tu Escuela o Empresa" + /> +
+

Si se deja en blanco, se creará una organización basada en el dominio de tu correo electrónico.

+
+ + )} + +
+ +
+ + setEmail(e.target.value)} + className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" + placeholder="estudiante@ejemplo.com" + required + /> +
-
- )} - {!isLogin && ( + +
+ +
+ + setPassword(e.target.value)} + className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" + placeholder="••••••••" + required + /> +
+
+ + ) : (
setOrganizationName(e.target.value)} + value={orgIdForSSO} + onChange={(e) => setOrgIdForSSO(e.target.value)} className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" - placeholder="Tu Escuela o Empresa" + placeholder="00000000-0000-0000-0000-000000000000" + required />
-

Si se deja en blanco, se creará una organización basada en el dominio de tu correo electrónico.

+

+ Contacta a tu administrador si no conoces el ID de tu organización. +

)} -
- -
- - setEmail(e.target.value)} - className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" - placeholder="estudiante@ejemplo.com" - required - /> -
-
- -
- -
- - setPassword(e.target.value)} - className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" - placeholder="••••••••" - required - /> -
-
- {error && (
{error} @@ -162,9 +197,39 @@ Contraseña + +
+
+ +
+
+ O bien +
+
+ + diff --git a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx index 2f9715d..ce602eb 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -303,7 +303,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les {hasTranscription && transcriptOpen && (