diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..222697181 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @element-hq/mas-maintainers diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 6d36f354b..03aadee4f 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -264,7 +264,11 @@ jobs: type=sha - name: Setup Cosign +<<<<<<< HEAD uses: sigstore/cosign-installer@v3.10.0 +======= + uses: sigstore/cosign-installer@v4.0.0 +>>>>>>> v1.6.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3.11.1 @@ -274,7 +278,7 @@ jobs: mirrors = ["mirror.gcr.io"] - name: Login to GitHub Container Registry - uses: docker/login-action@v3.5.0 + uses: docker/login-action@v3.6.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -333,7 +337,11 @@ jobs: merge-multiple: true - name: Prepare a release +<<<<<<< HEAD uses: softprops/action-gh-release@v2.3.3 +======= + uses: softprops/action-gh-release@v2.4.1 +>>>>>>> v1.6.0 with: generate_release_notes: true body: | @@ -402,7 +410,11 @@ jobs: await script({ core, github, context }); - name: Update unstable release +<<<<<<< HEAD uses: softprops/action-gh-release@v2.3.3 +======= + uses: softprops/action-gh-release@v2.4.1 +>>>>>>> v1.6.0 with: name: "Unstable build" tag_name: unstable diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 080fae3df..18752e972 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -64,7 +64,11 @@ jobs: uses: actions/checkout@v5 - name: Install Node +<<<<<<< HEAD uses: actions/setup-node@v5.0.0 +======= + uses: actions/setup-node@v6.0.0 +>>>>>>> v1.6.0 with: node-version: 22 @@ -88,7 +92,11 @@ jobs: uses: actions/checkout@v5 - name: Install Node +<<<<<<< HEAD uses: actions/setup-node@v5.0.0 +======= + uses: actions/setup-node@v6.0.0 +>>>>>>> v1.6.0 with: node-version: 22 @@ -112,7 +120,11 @@ jobs: uses: actions/checkout@v5 - name: Install Node +<<<<<<< HEAD uses: actions/setup-node@v5.0.0 +======= + uses: actions/setup-node@v6.0.0 +>>>>>>> v1.6.0 with: node-version: 20 diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index b33847ca3..d8e1c2da0 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -39,7 +39,11 @@ jobs: tool: mdbook - name: Install Node +<<<<<<< HEAD uses: actions/setup-node@v5.0.0 +======= + uses: actions/setup-node@v6.0.0 +>>>>>>> v1.6.0 with: node-version: 22 diff --git a/.github/workflows/release-branch.yaml b/.github/workflows/release-branch.yaml index 0c8148aea..d00584bc5 100644 --- a/.github/workflows/release-branch.yaml +++ b/.github/workflows/release-branch.yaml @@ -64,7 +64,11 @@ jobs: uses: actions/checkout@v5 - name: Install Node +<<<<<<< HEAD uses: actions/setup-node@v5.0.0 +======= + uses: actions/setup-node@v6.0.0 +>>>>>>> v1.6.0 with: node-version: 22 diff --git a/.github/workflows/translations-download.yaml b/.github/workflows/translations-download.yaml index 8694c24a9..5c0a50ce9 100644 --- a/.github/workflows/translations-download.yaml +++ b/.github/workflows/translations-download.yaml @@ -22,7 +22,11 @@ jobs: uses: actions/checkout@v5 - name: Install Node +<<<<<<< HEAD uses: actions/setup-node@v5.0.0 +======= + uses: actions/setup-node@v6.0.0 +>>>>>>> v1.6.0 with: node-version: 22 diff --git a/.github/workflows/translations-upload.yaml b/.github/workflows/translations-upload.yaml index 2ca273b4c..c8c984cbb 100644 --- a/.github/workflows/translations-upload.yaml +++ b/.github/workflows/translations-upload.yaml @@ -21,7 +21,11 @@ jobs: uses: actions/checkout@v5 - name: Install Node +<<<<<<< HEAD uses: actions/setup-node@v5.0.0 +======= + uses: actions/setup-node@v6.0.0 +>>>>>>> v1.6.0 with: node-version: 22 diff --git a/Cargo.lock b/Cargo.lock index 1cf0e03aa..6d4f8d0c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,12 +95,16 @@ dependencies = [ "bytes", "cfg-if", "http", +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "schemars 0.8.22", "serde", "serde_json", "serde_qs", - "thiserror 2.0.16", + "thiserror 2.0.17", "tower-layer", "tower-service", "tracing", @@ -184,9 +188,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.99" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "arbitrary" @@ -310,7 +314,11 @@ dependencies = [ "futures-timer", "futures-util", "http", +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "mime", "multer", "num-traits", @@ -362,7 +370,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ecdaff7c9cffa3614a9f9999bf9ee4c3078fe3ce4d6a6e161736b56febf2de" dependencies = [ "bytes", +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "serde", "serde_json", ] @@ -553,9 +565,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" dependencies = [ "axum-core", "bytes", @@ -572,8 +584,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", @@ -587,9 +598,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.2" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" dependencies = [ "bytes", "futures-core", @@ -598,7 +609,6 @@ dependencies = [ "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -607,14 +617,15 @@ dependencies = [ [[package]] name = "axum-extra" -version = "0.10.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" dependencies = [ "axum", "axum-core", "bytes", "cookie", + "form_urlencoded", "futures-util", "headers", "http", @@ -623,10 +634,12 @@ dependencies = [ "mime", "pin-project-lite", "rustversion", - "serde", - "tower", + "serde_core", + "serde_html_form", + "serde_path_to_error", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -822,9 +835,15 @@ dependencies = [ [[package]] name = "camino" +<<<<<<< HEAD version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1de8bc0aa9e9385ceb3bf0c152e3a9b9544f6c4a912c8ae504e80c1f0368603" +======= +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" +>>>>>>> v1.6.0 dependencies = [ "serde_core", ] @@ -971,9 +990,15 @@ dependencies = [ [[package]] name = "clap" +<<<<<<< HEAD version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" +======= +version = "4.5.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2cfd7bf8a6017ddaa4e32ffe7403d547790db06bd171c1c53926faab501623" +>>>>>>> v1.6.0 dependencies = [ "clap_builder", "clap_derive", @@ -981,9 +1006,15 @@ dependencies = [ [[package]] name = "clap_builder" +<<<<<<< HEAD version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" +======= +version = "4.5.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a4c05b9e80c5ccd3a7ef080ad7b6ba7d6fc00a985b8b157197075677c82c7a0" +>>>>>>> v1.6.0 dependencies = [ "anstream", "anstyle", @@ -993,9 +1024,15 @@ dependencies = [ [[package]] name = "clap_derive" +<<<<<<< HEAD version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" +======= +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +>>>>>>> v1.6.0 dependencies = [ "heck 0.5.0", "proc-macro2", @@ -1024,7 +1061,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -1623,7 +1660,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb333721800c025e363e902b293040778f8ac79913db4f013abf1f1d7d382fd7" dependencies = [ "rust_decimal", +<<<<<<< HEAD "thiserror 2.0.16", +======= + "thiserror 2.0.17", +>>>>>>> v1.6.0 "winnow 0.7.13", ] @@ -2057,7 +2098,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" dependencies = [ "fallible-iterator", +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "stable_deref_trait", ] @@ -2123,7 +2168,11 @@ dependencies = [ "futures-core", "futures-sink", "http", +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "slab", "tokio", "tokio-util", @@ -2731,9 +2780,15 @@ dependencies = [ [[package]] name = "indexmap" +<<<<<<< HEAD version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92119844f513ffa41556430369ab02c295a3578af21cf945caa3e9e0c2481ac3" +======= +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +>>>>>>> v1.6.0 dependencies = [ "equivalent", "hashbrown 0.15.5", @@ -2787,6 +2842,7 @@ dependencies = [ ] [[package]] +<<<<<<< HEAD name = "io-uring" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2798,6 +2854,8 @@ dependencies = [ ] [[package]] +======= +>>>>>>> v1.6.0 name = "ipnet" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2975,9 +3033,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lettre" -version = "0.11.18" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cb54db6ff7a89efac87dba5baeac57bb9ccd726b49a9b6f21fb92b3966aaf56" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" dependencies = [ "async-std", "async-trait", @@ -3106,7 +3164,11 @@ dependencies = [ [[package]] name = "mas-axum-utils" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "anyhow", "axum", @@ -3131,7 +3193,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tracing", "ulid", @@ -3140,7 +3202,11 @@ dependencies = [ [[package]] name = "mas-cli" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "anyhow", "axum", @@ -3213,7 +3279,11 @@ dependencies = [ [[package]] name = "mas-config" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "anyhow", "camino", @@ -3245,7 +3315,11 @@ dependencies = [ [[package]] name = "mas-context" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "console", "opentelemetry", @@ -3261,7 +3335,11 @@ dependencies = [ [[package]] name = "mas-data-model" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "base64ct", "chrono", @@ -3276,7 +3354,7 @@ dependencies = [ "ruma-common", "serde", "serde_json", - "thiserror 2.0.16", + "thiserror 2.0.17", "ulid", "url", "woothee", @@ -3284,18 +3362,26 @@ dependencies = [ [[package]] name = "mas-email" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "async-trait", "lettre", "mas-templates", - "thiserror 2.0.16", + "thiserror 2.0.17", "tracing", ] [[package]] name = "mas-handlers" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "aide", "anyhow", @@ -3317,7 +3403,11 @@ dependencies = [ "hex", "hyper", "icu_normalizer", +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "insta", "lettre", "mas-axum-utils", @@ -3359,8 +3449,12 @@ dependencies = [ "serde_with", "sha2", "sqlx", +<<<<<<< HEAD "tchap", "thiserror 2.0.16", +======= + "thiserror 2.0.17", +>>>>>>> v1.6.0 "tokio", "tokio-util", "tower", @@ -3376,7 +3470,11 @@ dependencies = [ [[package]] name = "mas-http" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "futures-util", "headers", @@ -3397,7 +3495,11 @@ dependencies = [ [[package]] name = "mas-i18n" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "camino", "icu_calendar", @@ -3413,13 +3515,17 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror 2.0.16", + "thiserror 2.0.17", "writeable", ] [[package]] name = "mas-i18n-scan" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "camino", "clap", @@ -3433,7 +3539,11 @@ dependencies = [ [[package]] name = "mas-iana" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "schemars 0.8.22", "serde", @@ -3441,7 +3551,11 @@ dependencies = [ [[package]] name = "mas-iana-codegen" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "anyhow", "async-trait", @@ -3457,7 +3571,11 @@ dependencies = [ [[package]] name = "mas-jose" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "base64ct", "chrono", @@ -3481,13 +3599,17 @@ dependencies = [ "serde_with", "sha2", "signature", - "thiserror 2.0.16", + "thiserror 2.0.17", "url", ] [[package]] name = "mas-keystore" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "aead", "base64ct", @@ -3510,12 +3632,16 @@ dependencies = [ "rsa", "sec1", "spki", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] name = "mas-listener" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "anyhow", "bytes", @@ -3527,7 +3653,7 @@ dependencies = [ "pin-project-lite", "rustls-pemfile", "socket2", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tokio-rustls", "tokio-test", @@ -3540,7 +3666,11 @@ dependencies = [ [[package]] name = "mas-matrix" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "anyhow", "async-trait", @@ -3550,7 +3680,11 @@ dependencies = [ [[package]] name = "mas-matrix-synapse" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "anyhow", "async-trait", @@ -3559,7 +3693,7 @@ dependencies = [ "mas-matrix", "reqwest", "serde", - "thiserror 2.0.16", + "thiserror 2.0.17", "tracing", "url", "urlencoding", @@ -3567,7 +3701,11 @@ dependencies = [ [[package]] name = "mas-oidc-client" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "assert_matches", "async-trait", @@ -3594,7 +3732,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tracing", "url", @@ -3603,7 +3741,11 @@ dependencies = [ [[package]] name = "mas-policy" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "anyhow", "arc-swap", @@ -3613,14 +3755,18 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tracing", ] [[package]] name = "mas-router" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "axum", "serde", @@ -3631,16 +3777,24 @@ dependencies = [ [[package]] name = "mas-spa" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "camino", "serde", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] name = "mas-storage" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "async-trait", "chrono", @@ -3653,7 +3807,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "serde_json", - "thiserror 2.0.16", + "thiserror 2.0.17", "tracing", "tracing-opentelemetry", "ulid", @@ -3662,7 +3816,11 @@ dependencies = [ [[package]] name = "mas-storage-pg" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "async-trait", "chrono", @@ -3679,8 +3837,9 @@ dependencies = [ "sea-query", "sea-query-binder", "serde_json", + "sha2", "sqlx", - "thiserror 2.0.16", + "thiserror 2.0.17", "tracing", "ulid", "url", @@ -3689,7 +3848,11 @@ dependencies = [ [[package]] name = "mas-tasks" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "anyhow", "async-trait", @@ -3711,7 +3874,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tokio-util", "tracing", @@ -3721,7 +3884,11 @@ dependencies = [ [[package]] name = "mas-templates" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "anyhow", "arc-swap", @@ -3740,7 +3907,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tracing", "ulid", @@ -3751,7 +3918,11 @@ dependencies = [ [[package]] name = "mas-tower" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "http", "opentelemetry", @@ -4021,12 +4192,20 @@ dependencies = [ [[package]] name = "oauth2-types" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "assert_matches", "base64ct", "chrono", +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "insta", "language-tags", "mas-iana", @@ -4035,7 +4214,7 @@ dependencies = [ "serde_json", "serde_with", "sha2", - "thiserror 2.0.16", + "thiserror 2.0.17", "url", ] @@ -4047,7 +4226,11 @@ checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" dependencies = [ "crc32fast", "hashbrown 0.15.5", +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "memchr", ] @@ -4091,7 +4274,7 @@ dependencies = [ "sha1", "sha2", "sprintf", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tracing", "urlencoding", @@ -4113,23 +4296,23 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "opentelemetry" -version = "0.30.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaf416e4cb72756655126f7dd7bb0af49c674f4c1b9903e80c009e0c37e552e6" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" dependencies = [ "futures-core", "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.16", + "thiserror 2.0.17", "tracing", ] [[package]] name = "opentelemetry-http" -version = "0.30.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50f6639e842a97dbea8886e3439710ae463120091e2e064518ba8e716e6ac36d" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" dependencies = [ "async-trait", "bytes", @@ -4140,18 +4323,18 @@ dependencies = [ [[package]] name = "opentelemetry-jaeger-propagator" -version = "0.30.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "090b8ec07bb2e304b529581aa1fe530d7861298c9ef549ebbf44a4a56472c539" +checksum = "ba3bbd907f151104a112f749f3b8387ef669b7264e0bb80546ea0700a3b307b7" dependencies = [ "opentelemetry", ] [[package]] name = "opentelemetry-otlp" -version = "0.30.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbee664a43e07615731afc539ca60c6d9f1a9425e25ca09c57bc36c87c55852b" +checksum = "7a2366db2dca4d2ad033cad11e6ee42844fd727007af5ad04a1730f4cb8163bf" dependencies = [ "http", "opentelemetry", @@ -4159,14 +4342,20 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] name = "opentelemetry-prometheus-text-exporter" +<<<<<<< HEAD version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddbb5743c13741bd9207de7c449f9797c0e513ac07551eac807da94056c530d9" +======= +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897906366b17a89bec845f6051e0c3474049402a09a0711eea180941293bd013" +>>>>>>> v1.6.0 dependencies = [ "opentelemetry", "opentelemetry_sdk", @@ -4175,21 +4364,22 @@ dependencies = [ [[package]] name = "opentelemetry-proto" -version = "0.30.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e046fd7660710fe5a05e8748e70d9058dc15c94ba914e7c4faa7c728f0e8ddc" +checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" dependencies = [ "opentelemetry", "opentelemetry_sdk", "prost", "tonic", + "tonic-prost", ] [[package]] name = "opentelemetry-resource-detectors" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a44e076f07fa3d76e741991f4f7d3ecbac0eed8521ced491fbdf8db77d024cf" +checksum = "e82845106cf72d47c141cee7f0d95e0650d8f28c6222a1f1ae727a8883899c19" dependencies = [ "opentelemetry", "opentelemetry-semantic-conventions", @@ -4198,15 +4388,15 @@ dependencies = [ [[package]] name = "opentelemetry-semantic-conventions" -version = "0.30.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d059a296a47436748557a353c5e6c5705b9470ef6c95cfc52c21a8814ddac2" +checksum = "e62e29dfe041afb8ed2a6c9737ab57db4907285d999ef8ad3a59092a36bdc846" [[package]] name = "opentelemetry-stdout" -version = "0.30.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "447191061af41c3943e082ea359ab8b64ff27d6d34d30d327df309ddef1eef6f" +checksum = "bc8887887e169414f637b18751487cce4e095be787d23fad13c454e2fb1b3811" dependencies = [ "chrono", "opentelemetry", @@ -4215,9 +4405,9 @@ dependencies = [ [[package]] name = "opentelemetry_sdk" -version = "0.30.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f644aa9e5e31d11896e024305d7e3c98a88884d9f8919dbf37a9991bc47a4b" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" dependencies = [ "futures-channel", "futures-executor", @@ -4225,8 +4415,7 @@ dependencies = [ "opentelemetry", "percent-encoding", "rand 0.9.2", - "serde_json", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tokio-stream", ] @@ -4375,20 +4564,31 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" +<<<<<<< HEAD version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21e0a3a33733faeaf8651dfee72dd0f388f0c8e5ad496a3478fa5a922f49cfa8" +======= +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" +>>>>>>> v1.6.0 dependencies = [ "memchr", - "thiserror 2.0.16", "ucd-trie", ] [[package]] name = "pest_derive" +<<<<<<< HEAD version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc58706f770acb1dbd0973e6530a3cff4746fb721207feb3a8a6064cd0b6c663" +======= +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" +>>>>>>> v1.6.0 dependencies = [ "pest", "pest_generator", @@ -4396,9 +4596,15 @@ dependencies = [ [[package]] name = "pest_generator" +<<<<<<< HEAD version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d4f36811dfe07f7b8573462465d5cb8965fffc2e71ae377a33aecf14c2c9a2f" +======= +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" +>>>>>>> v1.6.0 dependencies = [ "pest", "pest_meta", @@ -4409,9 +4615,15 @@ dependencies = [ [[package]] name = "pest_meta" +<<<<<<< HEAD version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42919b05089acbd0a5dcd5405fb304d17d1053847b81163d09c4ad18ce8e8420" +======= +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" +>>>>>>> v1.6.0 dependencies = [ "pest", "sha2", @@ -4529,7 +4741,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64", +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "quick-xml", "serde", "time", @@ -4657,9 +4873,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.13.5" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" dependencies = [ "bytes", "prost-derive", @@ -4667,9 +4883,9 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.13.5" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" dependencies = [ "anyhow", "itertools 0.14.0", @@ -4680,9 +4896,15 @@ dependencies = [ [[package]] name = "psl" +<<<<<<< HEAD version = "2.1.141" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98c10a4dce9ad24c1fad826cffc79a624cf626bfaddb466e969368a53d877b30" +======= +version = "2.1.148" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53297a72c400b31c5facd8e50894d08d20b74ee74925b28a20d51fe48c863583" +>>>>>>> v1.6.0 dependencies = [ "psl-types", ] @@ -4903,9 +5125,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.2" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", @@ -4915,9 +5137,15 @@ dependencies = [ [[package]] name = "regex-automata" +<<<<<<< HEAD version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +======= +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +>>>>>>> v1.6.0 dependencies = [ "aho-corasick", "memchr", @@ -4932,9 +5160,9 @@ checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "reqwest" -version = "0.12.23" +version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ "base64", "bytes", @@ -5017,15 +5245,19 @@ dependencies = [ [[package]] name = "ruma-common" -version = "0.15.4" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "387e1898e868d32ff7b205e7db327361d5dcf635c00a8ae5865068607595a9cf" +checksum = "ac7f59b9f7639667d0d6ae3ae242c8912e9ed061cea1fbaf72710a402e83b53e" dependencies = [ "as_variant", "base64", "bytes", "form_urlencoded", +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "js_int", "percent-encoding", "regex", @@ -5034,29 +5266,30 @@ dependencies = [ "serde", "serde_html_form", "serde_json", - "thiserror 2.0.16", + "thiserror 2.0.17", "time", "tracing", "url", "web-time", "wildmatch", + "zeroize", ] [[package]] name = "ruma-identifiers-validation" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ad674b5e5368c53a2c90fde7dac7e30747004aaf7b1827b72874a25fc06d4d8" +checksum = "14a7b93ac1e571c585f8fa5cef09c07bb8a15529775fd56b9a3eac4f9233dff2" dependencies = [ "js_int", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] name = "ruma-macros" -version = "0.15.2" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ff13fbd6045a7278533390826de316d6116d8582ed828352661337b0c422e1c" +checksum = "0c9911c7188517f28505d2d513339511d00e0f50cec5c2dde820cd0ec7e6a833" dependencies = [ "cfg-if", "proc-macro-crate", @@ -5114,9 +5347,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.31" +version = "0.23.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" +checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" dependencies = [ "aws-lc-rs", "log", @@ -5244,7 +5477,11 @@ dependencies = [ "chrono", "dyn-clone", "indexmap 1.9.3", +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "schemars_derive", "serde", "serde_json", @@ -5348,7 +5585,7 @@ dependencies = [ "proc-macro2", "quote", "syn", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -5402,9 +5639,9 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "sentry" -version = "0.42.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989425268ab5c011e06400187eed6c298272f8ef913e49fcadc3fda788b45030" +checksum = "48b85e25e8a1fc13928885e8bf13abe8a09e15c46993aed05d6405f7755d6e20" dependencies = [ "httpdate", "reqwest", @@ -5419,9 +5656,9 @@ dependencies = [ [[package]] name = "sentry-backtrace" -version = "0.42.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68e299dd3f7bcf676875eee852c9941e1d08278a743c32ca528e2debf846a653" +checksum = "f3253a495ab536f6de1746a58d5d7824b77d75e08e1a4b8ca6fb356839077ae0" dependencies = [ "backtrace", "regex", @@ -5430,9 +5667,9 @@ dependencies = [ [[package]] name = "sentry-contexts" -version = "0.42.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac0c5d6892cd4c414492fc957477b620026fb3411fca9fa12774831da561c88" +checksum = "027f81a728836e66b88c07666a10f5ed5a35e2695b04eb7aa0fcbed93f814900" dependencies = [ "hostname", "libc", @@ -5444,9 +5681,9 @@ dependencies = [ [[package]] name = "sentry-core" -version = "0.42.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deaa38b94e70820ff3f1f9db3c8b0aef053b667be130f618e615e0ff2492cbcc" +checksum = "d3b6729c8e71ac968edbe9bf2dd4109c162e552b52bacd2b07e24ede1aba84a5" dependencies = [ "rand 0.9.2", "sentry-types", @@ -5457,9 +5694,9 @@ dependencies = [ [[package]] name = "sentry-panic" -version = "0.42.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b7a23b13c004873de3ce7db86eb0f59fe4adfc655a31f7bbc17fd10bacc9bfe" +checksum = "1ac0471f04f8f97af0c17eeca2c516e23faa1c0271a55bc64371d9ce488c2d40" dependencies = [ "sentry-backtrace", "sentry-core", @@ -5467,9 +5704,9 @@ dependencies = [ [[package]] name = "sentry-tower" -version = "0.42.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a303d0127d95ae928a937dcc0886931d28b4186e7338eea7d5786827b69b002" +checksum = "417bd48071863a65ca5f33d15af9aabd49a5cee7f97415d3f08ce8c90ed2c531" dependencies = [ "axum", "http", @@ -5482,9 +5719,9 @@ dependencies = [ [[package]] name = "sentry-tracing" -version = "0.42.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac841c7050aa73fc2bec8f7d8e9cb1159af0b3095757b99820823f3e54e5080" +checksum = "428f780866a613142dcc81b7f8551ae4d1c056f4df22b6d7ddd9154a9974eb03" dependencies = [ "bitflags", "sentry-backtrace", @@ -5495,16 +5732,16 @@ dependencies = [ [[package]] name = "sentry-types" -version = "0.42.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e477f4d4db08ddb4ab553717a8d3a511bc9e81dde0c808c680feacbb8105c412" +checksum = "2c19d1d1967b55659c358886d0f1aa3076488d445f84c7d727d384c675adaec1" dependencies = [ "debugid", "hex", "rand 0.9.2", "serde", "serde_json", - "thiserror 2.0.16", + "thiserror 2.0.17", "time", "url", "uuid", @@ -5512,9 +5749,15 @@ dependencies = [ [[package]] name = "serde" +<<<<<<< HEAD version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" +======= +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +>>>>>>> v1.6.0 dependencies = [ "serde_core", "serde_derive", @@ -5522,18 +5765,30 @@ dependencies = [ [[package]] name = "serde_core" +<<<<<<< HEAD version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" +======= +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +>>>>>>> v1.6.0 dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" +<<<<<<< HEAD version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" +======= +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +>>>>>>> v1.6.0 dependencies = [ "proc-macro2", "quote", @@ -5558,7 +5813,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" dependencies = [ "form_urlencoded", +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "itoa", "ryu", "serde_core", @@ -5570,7 +5829,11 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "itoa", "memchr", "ryu", @@ -5599,7 +5862,7 @@ dependencies = [ "futures", "percent-encoding", "serde", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -5633,7 +5896,11 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "schemars 0.9.0", "schemars 1.0.4", "serde", @@ -5661,7 +5928,11 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "itoa", "ryu", "serde", @@ -5760,6 +6031,7 @@ dependencies = [ [[package]] name = "smartstring" version = "1.0.1" +<<<<<<< HEAD source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" dependencies = [ @@ -5771,11 +6043,24 @@ dependencies = [ [[package]] name = "socket2" version = "0.6.0" +======= +>>>>>>> v1.6.0 +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + +[[package]] +name = "socket2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -5812,7 +6097,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78222247fc55e10208ed1ba60f8296390bc67a489bc27a36231765d8d6f60ec5" dependencies = [ - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -5847,7 +6132,11 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "ipnetwork", "log", "memchr", @@ -5858,7 +6147,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tokio-stream", "tracing", @@ -5943,7 +6232,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.16", + "thiserror 2.0.17", "tracing", "uuid", "whoami", @@ -5983,7 +6272,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.16", + "thiserror 2.0.17", "tracing", "uuid", "whoami", @@ -6009,7 +6298,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.16", + "thiserror 2.0.17", "tracing", "url", "uuid", @@ -6104,7 +6393,11 @@ dependencies = [ [[package]] name = "syn2mas" +<<<<<<< HEAD version = "1.3.0" +======= +version = "1.6.0" +>>>>>>> v1.6.0 dependencies = [ "anyhow", "arc-swap", @@ -6129,7 +6422,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.16", + "thiserror 2.0.17", "thiserror-ext", "tokio", "tokio-util", @@ -6164,6 +6457,7 @@ name = "target-lexicon" version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +<<<<<<< HEAD [[package]] name = "tchap" @@ -6180,6 +6474,8 @@ dependencies = [ "url", "wiremock", ] +======= +>>>>>>> v1.6.0 [[package]] name = "tempfile" @@ -6214,11 +6510,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.16" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.16", + "thiserror-impl 2.0.17", ] [[package]] @@ -6227,7 +6523,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fb7e61141f4141832ca9aad63c3c90023843f944a1975460abdacc64d03f534" dependencies = [ - "thiserror 2.0.16", + "thiserror 2.0.17", "thiserror-ext-derive", ] @@ -6256,9 +6552,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.16" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", @@ -6333,29 +6629,26 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.47.1" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", "socket2", "tokio-macros", - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", @@ -6364,9 +6657,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", @@ -6437,7 +6730,11 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "serde", "serde_spanned", "toml_datetime", @@ -6446,9 +6743,9 @@ dependencies = [ [[package]] name = "tonic" -version = "0.13.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" +checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" dependencies = [ "async-trait", "base64", @@ -6458,13 +6755,24 @@ dependencies = [ "http-body-util", "percent-encoding", "pin-project", - "prost", + "sync_wrapper", "tokio-stream", "tower-layer", "tower-service", "tracing", ] +[[package]] +name = "tonic-prost" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67" +dependencies = [ + "bytes", + "prost", + "tonic", +] + [[package]] name = "tower" version = "0.5.2" @@ -6591,14 +6899,15 @@ dependencies = [ [[package]] name = "tracing-opentelemetry" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddcf5959f39507d0d04d6413119c04f33b623f4f951ebcbdddddfad2d0623a9c" +checksum = "1e6e5658463dd88089aba75c7791e1d3120633b1bfde22478b28f625a9bb1b8e" dependencies = [ "js-sys", - "once_cell", "opentelemetry", "opentelemetry_sdk", + "rustversion", + "thiserror 2.0.17", "tracing", "tracing-core", "tracing-subscriber", @@ -7015,7 +7324,11 @@ checksum = "161296c618fa2d63f6ed5fffd1112937e803cb9ec71b32b01a76321555660917" dependencies = [ "bitflags", "hashbrown 0.15.5", +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "semver", "serde", ] @@ -7045,7 +7358,11 @@ dependencies = [ "cc", "cfg-if", "hashbrown 0.15.5", +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "libc", "log", "mach2", @@ -7085,7 +7402,11 @@ dependencies = [ "cranelift-bitset", "cranelift-entity", "gimli", +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "log", "object", "postcard", @@ -7148,7 +7469,7 @@ dependencies = [ "pulley-interpreter", "smallvec", "target-lexicon", - "thiserror 2.0.16", + "thiserror 2.0.17", "wasmparser", "wasmtime-environ", "wasmtime-internal-math", @@ -7230,7 +7551,11 @@ checksum = "1ae057d44a5b60e6ec529b0c21809a9d1fc92e91ef6e0f6771ed11dd02a94a08" dependencies = [ "anyhow", "heck 0.5.0", +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "wit-parser", ] @@ -7745,7 +8070,11 @@ checksum = "0a1f95a87d03a33e259af286b857a95911eb46236a0f726cbaec1227b3dfc67a" dependencies = [ "anyhow", "id-arena", +<<<<<<< HEAD "indexmap 2.11.3", +======= + "indexmap 2.11.4", +>>>>>>> v1.6.0 "log", "semver", "serde", @@ -7853,9 +8182,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 97579b235..36b2ade8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,11 @@ members = ["crates/*"] resolver = "2" # Updated in the CI with a `sed` command +<<<<<<< HEAD package.version = "1.3.0" +======= +package.version = "1.6.0" +>>>>>>> v1.6.0 package.license = "AGPL-3.0-only OR LicenseRef-Element-Commercial" package.authors = ["Element Backend Team"] package.edition = "2024" @@ -34,6 +38,7 @@ broken_intra_doc_links = "deny" [workspace.dependencies] # Workspace crates +<<<<<<< HEAD mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.3.0" } mas-cli = { path = "./crates/cli/", version = "=1.3.0" } mas-config = { path = "./crates/config/", version = "=1.3.0" } @@ -66,11 +71,42 @@ syn2mas = { path = "./crates/syn2mas", version = "=1.3.0" } #:tchap: tchap = { path = "./crates/tchap", version = "=0.1.0" } #:tchap: +======= +mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.6.0" } +mas-cli = { path = "./crates/cli/", version = "=1.6.0" } +mas-config = { path = "./crates/config/", version = "=1.6.0" } +mas-context = { path = "./crates/context/", version = "=1.6.0" } +mas-data-model = { path = "./crates/data-model/", version = "=1.6.0" } +mas-email = { path = "./crates/email/", version = "=1.6.0" } +mas-graphql = { path = "./crates/graphql/", version = "=1.6.0" } +mas-handlers = { path = "./crates/handlers/", version = "=1.6.0" } +mas-http = { path = "./crates/http/", version = "=1.6.0" } +mas-i18n = { path = "./crates/i18n/", version = "=1.6.0" } +mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.6.0" } +mas-iana = { path = "./crates/iana/", version = "=1.6.0" } +mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.6.0" } +mas-jose = { path = "./crates/jose/", version = "=1.6.0" } +mas-keystore = { path = "./crates/keystore/", version = "=1.6.0" } +mas-listener = { path = "./crates/listener/", version = "=1.6.0" } +mas-matrix = { path = "./crates/matrix/", version = "=1.6.0" } +mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.6.0" } +mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.6.0" } +mas-policy = { path = "./crates/policy/", version = "=1.6.0" } +mas-router = { path = "./crates/router/", version = "=1.6.0" } +mas-spa = { path = "./crates/spa/", version = "=1.6.0" } +mas-storage = { path = "./crates/storage/", version = "=1.6.0" } +mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.6.0" } +mas-tasks = { path = "./crates/tasks/", version = "=1.6.0" } +mas-templates = { path = "./crates/templates/", version = "=1.6.0" } +mas-tower = { path = "./crates/tower/", version = "=1.6.0" } +oauth2-types = { path = "./crates/oauth2-types/", version = "=1.6.0" } +syn2mas = { path = "./crates/syn2mas", version = "=1.6.0" } +>>>>>>> v1.6.0 # OpenAPI schema generation and validation [workspace.dependencies.aide] version = "0.14.2" -features = ["axum", "axum-extra", "axum-json", "axum-query", "macros"] +features = ["axum", "axum-extra", "axum-extra-query", "axum-json", "macros"] # An `Arc` that can be atomically updated [workspace.dependencies.arc-swap] @@ -91,7 +127,7 @@ version = "0.1.89" # High-level error handling [workspace.dependencies.anyhow] -version = "1.0.99" +version = "1.0.100" # Assert that a value matches a pattern [workspace.dependencies.assert_matches] @@ -99,12 +135,12 @@ version = "1.5.0" # HTTP router [workspace.dependencies.axum] -version = "0.8.4" +version = "0.8.6" # Extra utilities for Axum [workspace.dependencies.axum-extra] -version = "0.10.1" -features = ["cookie-private", "cookie-key-expansion", "typed-header"] +version = "0.10.3" +features = ["cookie-private", "cookie-key-expansion", "typed-header", "query"] # Axum macros [workspace.dependencies.axum-macros] @@ -140,7 +176,11 @@ version = "1.10.1" # UTF-8 paths [workspace.dependencies.camino] +<<<<<<< HEAD version = "1.2.0" +======= +version = "1.2.1" +>>>>>>> v1.6.0 features = ["serde1"] # ChaCha20Poly1305 AEAD @@ -170,7 +210,11 @@ features = ["serde", "clock"] # CLI argument parsing [workspace.dependencies.clap] +<<<<<<< HEAD version = "4.5.47" +======= +version = "4.5.50" +>>>>>>> v1.6.0 features = ["derive"] # Object Identifiers (OIDs) as constants @@ -324,7 +368,11 @@ features = ["std"] # HashMap which preserves insertion order [workspace.dependencies.indexmap] +<<<<<<< HEAD version = "2.11.3" +======= +version = "2.11.4" +>>>>>>> v1.6.0 features = ["serde"] # Indented string literals @@ -357,7 +405,7 @@ features = ["serde"] # Email sending [workspace.dependencies.lettre] -version = "0.11.18" +version = "0.11.19" default-features = false features = [ "tokio1-rustls", @@ -399,36 +447,40 @@ version = "0.1.7" # OpenTelemetry [workspace.dependencies.opentelemetry] -version = "0.30.0" +version = "0.31.0" features = ["trace", "metrics"] [workspace.dependencies.opentelemetry-http] -version = "0.30.0" +version = "0.31.0" features = ["reqwest"] [workspace.dependencies.opentelemetry-jaeger-propagator] -version = "0.30.0" +version = "0.31.0" [workspace.dependencies.opentelemetry-otlp] -version = "0.30.0" +version = "0.31.0" default-features = false features = ["trace", "metrics", "http-proto"] [workspace.dependencies.opentelemetry-prometheus-text-exporter] +<<<<<<< HEAD version = "0.2.0" +======= +version = "0.2.1" +>>>>>>> v1.6.0 [workspace.dependencies.opentelemetry-resource-detectors] -version = "0.9.0" +version = "0.10.0" [workspace.dependencies.opentelemetry-semantic-conventions] -version = "0.30.0" +version = "0.31.0" features = ["semconv_experimental"] [workspace.dependencies.opentelemetry-stdout] -version = "0.30.0" +version = "0.31.0" features = ["trace", "metrics"] [workspace.dependencies.opentelemetry_sdk] -version = "0.30.0" +version = "0.31.0" features = [ "experimental_trace_batch_span_processor_with_async_runtime", "experimental_metrics_periodicreader_with_async_runtime", "rt-tokio", ] [workspace.dependencies.tracing-opentelemetry] -version = "0.31.0" +version = "0.32.0" default-features = false # P256 elliptic curve @@ -457,11 +509,19 @@ features = ["std"] # Parser generator [workspace.dependencies.pest] +<<<<<<< HEAD version = "2.8.2" # Pest derive macros [workspace.dependencies.pest_derive] version = "2.8.2" +======= +version = "2.8.3" + +# Pest derive macros +[workspace.dependencies.pest_derive] +version = "2.8.3" +>>>>>>> v1.6.0 # Pin projection [workspace.dependencies.pin-project-lite] @@ -479,7 +539,11 @@ features = ["std", "pkcs5", "encryption"] # Public Suffix List [workspace.dependencies.psl] +<<<<<<< HEAD version = "2.1.141" +======= +version = "2.1.148" +>>>>>>> v1.6.0 # High-precision clock [workspace.dependencies.quanta] @@ -495,11 +559,11 @@ version = "0.6.4" # Regular expressions [workspace.dependencies.regex] -version = "1.11.2" +version = "1.12.2" # High-level HTTP client [workspace.dependencies.reqwest] -version = "0.12.23" +version = "0.12.24" default-features = false features = [ "http2", @@ -520,11 +584,11 @@ version = "2.1.1" # Matrix-related types [workspace.dependencies.ruma-common] -version = "0.15.4" +version = "0.16.0" # TLS stack [workspace.dependencies.rustls] -version = "0.23.31" +version = "0.23.32" # PEM parsing for rustls [workspace.dependencies.rustls-pemfile] @@ -570,22 +634,26 @@ features = [ # Sentry error tracking [workspace.dependencies.sentry] -version = "0.42.0" +version = "0.45.0" default-features = false features = ["backtrace", "contexts", "panic", "tower", "reqwest"] # Sentry tower layer [workspace.dependencies.sentry-tower] -version = "0.42.0" +version = "0.45.0" features = ["http", "axum-matched-path"] # Sentry tracing integration [workspace.dependencies.sentry-tracing] -version = "0.42.0" +version = "0.45.0" # Serialization and deserialization [workspace.dependencies.serde] +<<<<<<< HEAD version = "1.0.225" +======= +version = "1.0.228" +>>>>>>> v1.6.0 features = ["derive"] # Most of the time, if we need serde, we need derive # JSON serialization and deserialization @@ -617,7 +685,7 @@ version = "2.2.0" # Low-level socket manipulation [workspace.dependencies.socket2] -version = "0.6.0" +version = "0.6.1" # Subject Public Key Info [workspace.dependencies.spki] @@ -640,14 +708,14 @@ features = [ # Custom error types [workspace.dependencies.thiserror] -version = "2.0.16" +version = "2.0.17" [workspace.dependencies.thiserror-ext] version = "0.3.0" # Async runtime [workspace.dependencies.tokio] -version = "1.47.1" +version = "1.48.0" features = ["full"] [workspace.dependencies.tokio-stream] @@ -655,7 +723,7 @@ version = "0.1.17" # Tokio rustls integration [workspace.dependencies.tokio-rustls] -version = "0.26.2" +version = "0.26.4" # Tokio test utilities [workspace.dependencies.tokio-test] @@ -738,7 +806,7 @@ version = "0.5.5" # Zero memory after use [workspace.dependencies.zeroize] -version = "1.8.1" +version = "1.8.2" # Password strength estimation [workspace.dependencies.zxcvbn] diff --git a/clippy.toml b/clippy.toml index 218811441..db1ba69dc 100644 --- a/clippy.toml +++ b/clippy.toml @@ -17,4 +17,6 @@ disallowed-methods = [ disallowed-types = [ { path = "std::path::PathBuf", reason = "use camino::Utf8PathBuf instead" }, { path = "std::path::Path", reason = "use camino::Utf8Path instead" }, + { path = "axum::extract::Query", reason = "use axum_extra::extract::Query instead. The built-in version doesn't deserialise lists."}, + { path = "axum::extract::rejection::QueryRejection", reason = "use axum_extra::extract::QueryRejection instead"} ] diff --git a/crates/cli/src/app_state.rs b/crates/cli/src/app_state.rs index 39c82eadb..8a740b281 100644 --- a/crates/cli/src/app_state.rs +++ b/crates/cli/src/app_state.rs @@ -9,6 +9,7 @@ use std::{convert::Infallible, net::IpAddr, sync::Arc}; use axum::extract::{FromRef, FromRequestParts}; use ipnetwork::IpNetwork; use mas_context::LogContext; +<<<<<<< HEAD use mas_data_model::{ BoxClock, BoxRng, @@ -17,6 +18,9 @@ use mas_data_model::{ //:tchap: TchapConfig, //:tchap:end }; +======= +use mas_data_model::{AppVersion, BoxClock, BoxRng, SiteConfig, SystemClock}; +>>>>>>> v1.6.0 use mas_handlers::{ ActivityTracker, BoundActivityTracker, CookieManager, ErrorWrapper, GraphQLSchema, Limiter, MetadataCache, RequesterFingerprint, passwords::PasswordManager, @@ -34,7 +38,7 @@ use rand::SeedableRng; use sqlx::PgPool; use tracing::Instrument; -use crate::telemetry::METER; +use crate::{VERSION, telemetry::METER}; #[derive(Clone)] pub struct AppState { @@ -224,6 +228,7 @@ impl FromRef for Arc { } } +<<<<<<< HEAD //:tchap: impl FromRef for TchapConfig { fn from_ref(input: &AppState) -> Self { @@ -231,6 +236,13 @@ impl FromRef for TchapConfig { } } //:tchap:end +======= +impl FromRef for AppVersion { + fn from_ref(_input: &AppState) -> Self { + AppVersion(VERSION) + } +} +>>>>>>> v1.6.0 impl FromRequestParts for BoxClock { type Rejection = Infallible; diff --git a/crates/cli/src/commands/manage.rs b/crates/cli/src/commands/manage.rs index 0b0121078..e5775e24c 100644 --- a/crates/cli/src/commands/manage.rs +++ b/crates/cli/src/commands/manage.rs @@ -390,9 +390,16 @@ impl Options { info!("The following users can request admin privileges ({total} total):"); loop { let page = repo.user().list(filter, cursor).await?; +<<<<<<< HEAD for user in page.edges { info!(%user.id, username = %user.username); cursor = cursor.after(user.id); +======= + for edge in page.edges { + let user = edge.node; + info!(%user.id, username = %user.username); + cursor = cursor.after(edge.cursor); +>>>>>>> v1.6.0 } if !page.has_next_page { diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index e319cad2d..f6347e341 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -182,8 +182,14 @@ impl Options { //:tchap: end // Load and compile the templates - let templates = - templates_from_config(&config.templates, &site_config, &url_builder).await?; + let templates = templates_from_config( + &config.templates, + &site_config, + &url_builder, + // Don't use strict mode in production yet + false, + ) + .await?; shutdown.register_reloadable(&templates); let http_client = mas_http::reqwest_client(); diff --git a/crates/cli/src/commands/templates.rs b/crates/cli/src/commands/templates.rs index 111f19682..8f1b0dd4e 100644 --- a/crates/cli/src/commands/templates.rs +++ b/crates/cli/src/commands/templates.rs @@ -4,8 +4,10 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. -use std::process::ExitCode; +use std::{fmt::Write, process::ExitCode}; +use anyhow::{Context as _, bail}; +use camino::Utf8PathBuf; use clap::Parser; use figment::Figment; use mas_config::{ @@ -27,14 +29,19 @@ pub(super) struct Options { #[derive(Parser, Debug)] enum Subcommand { /// Check that the templates specified in the config are valid - Check, + Check { + /// If set, templates will be rendered to this directory. + /// The directory must either not exist or be empty. + #[arg(long = "out-dir")] + out_dir: Option, + }, } impl Options { pub async fn run(self, figment: &Figment) -> anyhow::Result { use Subcommand as SC; match self.subcommand { - SC::Check => { + SC::Check { out_dir } => { let _span = info_span!("cli.templates.check").entered(); let template_config = TemplatesConfig::extract_or_default(figment) @@ -65,9 +72,54 @@ impl Options { &account_config, &captcha_config, )?; - let templates = - templates_from_config(&template_config, &site_config, &url_builder).await?; - templates.check_render(clock.now(), &mut rng)?; + let templates = templates_from_config( + &template_config, + &site_config, + &url_builder, // Use strict mode in template checks + true, + ) + .await?; + let all_renders = templates.check_render(clock.now(), &mut rng)?; + + if let Some(out_dir) = out_dir { + // Save renders to disk. + if out_dir.exists() { + let mut read_dir = + tokio::fs::read_dir(&out_dir).await.with_context(|| { + format!("could not read {out_dir} to check it's empty") + })?; + if read_dir.next_entry().await?.is_some() { + bail!("Render directory {out_dir} is not empty, refusing to write."); + } + } else { + tokio::fs::create_dir(&out_dir) + .await + .with_context(|| format!("could not create {out_dir}"))?; + } + + for ((template, sample_identifier), template_render) in &all_renders { + let (template_filename_base, template_ext) = + template.rsplit_once('.').unwrap_or((template, "txt")); + let template_filename_base = template_filename_base.replace('/', "_"); + + // Make a string like `-index=0-browser-session=0-locale=fr` + let sample_suffix = { + let mut s = String::new(); + for (k, v) in &sample_identifier.components { + write!(s, "-{k}={v}")?; + } + s + }; + + let render_path = out_dir.join(format!( + "{template_filename_base}{sample_suffix}.{template_ext}" + )); + + tokio::fs::write(&render_path, template_render.as_bytes()) + .await + .with_context(|| format!("could not write render to {render_path}"))?; + } + } Ok(ExitCode::SUCCESS) } diff --git a/crates/cli/src/commands/worker.rs b/crates/cli/src/commands/worker.rs index 31d0d56c2..a1eb0fcce 100644 --- a/crates/cli/src/commands/worker.rs +++ b/crates/cli/src/commands/worker.rs @@ -52,8 +52,14 @@ impl Options { )?; // Load and compile the templates - let templates = - templates_from_config(&config.templates, &site_config, &url_builder).await?; + let templates = templates_from_config( + &config.templates, + &site_config, + &url_builder, + // Don't use strict mode on task workers for now + false, + ) + .await?; let mailer = mailer_from_config(&config.email, &templates)?; test_mailer_in_background(&mailer, Duration::from_secs(30)); diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 5df40da83..9c1121cca 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -149,12 +149,14 @@ async fn try_main() -> anyhow::Result { // Setup OpenTelemetry tracing and metrics self::telemetry::setup(&telemetry_config).context("failed to setup OpenTelemetry")?; - let telemetry_layer = self::telemetry::TRACER.get().map(|tracer| { - tracing_opentelemetry::layer() - .with_tracer(tracer.clone()) - .with_tracked_inactivity(false) - .with_filter(LevelFilter::INFO) - }); + let tracer = self::telemetry::TRACER + .get() + .context("TRACER was not set")?; + + let telemetry_layer = tracing_opentelemetry::layer() + .with_tracer(tracer.clone()) + .with_tracked_inactivity(false) + .with_filter(LevelFilter::INFO); let subscriber = Registry::default() .with(suppress_layer) diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index 9ce9b3a52..58d3672e0 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -136,14 +136,24 @@ fn make_http_span(req: &Request) -> Span { span.record(USER_AGENT_ORIGINAL, user_agent); } - // Extract the parent span context from the request headers - let parent_context = opentelemetry::global::get_text_map_propagator(|propagator| { - let extractor = HeaderExtractor(req.headers()); - let context = opentelemetry::Context::new(); - propagator.extract_with_context(&context, &extractor) - }); - - span.set_parent(parent_context); + // In case the span is disabled by any of tracing layers, e.g. if `RUST_LOG` + // is set to `warn`, `set_parent` will fail. So we only try to set the + // parent context if the span is not disabled. + if !span.is_disabled() { + // Extract the parent span context from the request headers + let parent_context = opentelemetry::global::get_text_map_propagator(|propagator| { + let extractor = HeaderExtractor(req.headers()); + let context = opentelemetry::Context::new(); + propagator.extract_with_context(&context, &extractor) + }); + + if let Err(err) = span.set_parent(parent_context) { + tracing::error!( + error = &err as &dyn std::error::Error, + "Failed to set parent context on span" + ); + } + } span } diff --git a/crates/cli/src/sync.rs b/crates/cli/src/sync.rs index 4b8c388c3..e66b3aa50 100644 --- a/crates/cli/src/sync.rs +++ b/crates/cli/src/sync.rs @@ -132,7 +132,8 @@ pub async fn config_sync( let mut existing_enabled_ids = BTreeSet::new(); let mut existing_disabled = BTreeMap::new(); // Process the existing providers - for provider in page.edges { + for edge in page.edges { + let provider = edge.node; if provider.enabled() { if config_ids.contains(&provider.id) { existing_enabled_ids.insert(provider.id); diff --git a/crates/cli/src/telemetry.rs b/crates/cli/src/telemetry.rs index 274ae770e..8c07a53f2 100644 --- a/crates/cli/src/telemetry.rs +++ b/crates/cli/src/telemetry.rs @@ -29,11 +29,15 @@ use opentelemetry_sdk::{ metrics::{ManualReader, SdkMeterProvider, periodic_reader_with_async_runtime::PeriodicReader}, propagation::{BaggagePropagator, TraceContextPropagator}, trace::{ - Sampler, SdkTracerProvider, Tracer, span_processor_with_async_runtime::BatchSpanProcessor, + IdGenerator, Sampler, SdkTracerProvider, Tracer, + span_processor_with_async_runtime::BatchSpanProcessor, }, }; use opentelemetry_semantic_conventions as semcov; +<<<<<<< HEAD use url::Url; +======= +>>>>>>> v1.6.0 static SCOPE: LazyLock = LazyLock::new(|| { InstrumentationScope::builder(env!("CARGO_PKG_NAME")) @@ -94,50 +98,65 @@ fn propagator(propagators: &[Propagator]) -> TextMapCompositePropagator { TextMapCompositePropagator::new(propagators) } -fn stdout_tracer_provider() -> SdkTracerProvider { - let exporter = opentelemetry_stdout::SpanExporter::default(); - SdkTracerProvider::builder() - .with_simple_exporter(exporter) - .build() -} - -fn otlp_tracer_provider( - endpoint: Option<&Url>, - sample_rate: f64, -) -> anyhow::Result { - let mut exporter = opentelemetry_otlp::SpanExporter::builder() - .with_http() - .with_http_client(mas_http::reqwest_client()); - if let Some(endpoint) = endpoint { - exporter = exporter.with_endpoint(endpoint.to_string()); +/// An [`IdGenerator`] which always returns an invalid trace ID and span ID +/// +/// This is used when no exporter is being used, so that we don't log the trace +/// ID when we're not tracing. +#[derive(Debug, Clone, Copy)] +struct InvalidIdGenerator; +impl IdGenerator for InvalidIdGenerator { + fn new_trace_id(&self) -> opentelemetry::TraceId { + opentelemetry::TraceId::INVALID } - let exporter = exporter - .build() - .context("Failed to configure OTLP trace exporter")?; + fn new_span_id(&self) -> opentelemetry::SpanId { + opentelemetry::SpanId::INVALID + } +} - let batch_processor = - BatchSpanProcessor::builder(exporter, opentelemetry_sdk::runtime::Tokio).build(); +fn init_tracer(config: &TracingConfig) -> anyhow::Result<()> { + let sample_rate = config.sample_rate.unwrap_or(1.0); // We sample traces based on the parent if we have one, and if not, we // sample a ratio based on the configured sample rate let sampler = Sampler::ParentBased(Box::new(Sampler::TraceIdRatioBased(sample_rate))); - let tracer_provider = SdkTracerProvider::builder() - .with_span_processor(batch_processor) + let tracer_provider_builder = SdkTracerProvider::builder() .with_resource(resource()) - .with_sampler(sampler) - .build(); + .with_sampler(sampler); - Ok(tracer_provider) -} - -fn init_tracer(config: &TracingConfig) -> anyhow::Result<()> { - let sample_rate = config.sample_rate.unwrap_or(1.0); let tracer_provider = match config.exporter { - TracingExporterKind::None => return Ok(()), - TracingExporterKind::Stdout => stdout_tracer_provider(), - TracingExporterKind::Otlp => otlp_tracer_provider(config.endpoint.as_ref(), sample_rate)?, + TracingExporterKind::None => tracer_provider_builder + .with_id_generator(InvalidIdGenerator) + .with_sampler(Sampler::AlwaysOff) + .build(), + + TracingExporterKind::Stdout => { + let exporter = opentelemetry_stdout::SpanExporter::default(); + tracer_provider_builder + .with_simple_exporter(exporter) + .build() + } + + TracingExporterKind::Otlp => { + let mut exporter = opentelemetry_otlp::SpanExporter::builder() + .with_http() + .with_http_client(mas_http::reqwest_client()); + if let Some(endpoint) = &config.endpoint { + exporter = exporter.with_endpoint(endpoint.as_str()); + } + let exporter = exporter + .build() + .context("Failed to configure OTLP trace exporter")?; + + let batch_processor = + BatchSpanProcessor::builder(exporter, opentelemetry_sdk::runtime::Tokio).build(); + + tracer_provider_builder + .with_span_processor(batch_processor) + .build() + } }; + TRACER_PROVIDER .set(tracer_provider.clone()) .map_err(|_| anyhow::anyhow!("TRACER_PROVIDER was set twice"))?; diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 959c8ba0f..4925d9866 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -211,6 +211,7 @@ pub fn site_config_from_config( password_login_enabled: password_config.enabled(), password_registration_enabled: password_config.enabled() && account_config.password_registration_enabled, + password_registration_email_required: account_config.password_registration_email_required, registration_token_required: account_config.registration_token_required, email_change_allowed: account_config.email_change_allowed, displayname_change_allowed: account_config.displayname_change_allowed, @@ -231,6 +232,7 @@ pub async fn templates_from_config( config: &TemplatesConfig, site_config: &SiteConfig, url_builder: &UrlBuilder, + strict: bool, ) -> Result { Templates::load( config.path.clone(), @@ -239,6 +241,7 @@ pub async fn templates_from_config( config.translations_path.clone(), site_config.templates_branding(), site_config.templates_features(), + strict, ) .await .with_context(|| format!("Failed to load the templates at {}", config.path)) diff --git a/crates/config/src/sections/account.rs b/crates/config/src/sections/account.rs index 47efa0162..2b6538a2b 100644 --- a/crates/config/src/sections/account.rs +++ b/crates/config/src/sections/account.rs @@ -50,6 +50,13 @@ pub struct AccountConfig { #[serde(default = "default_false", skip_serializing_if = "is_default_false")] pub password_registration_enabled: bool, + /// Whether self-service password registrations require a valid email. + /// Defaults to `true`. + /// + /// This has no effect if password registration is disabled. + #[serde(default = "default_true", skip_serializing_if = "is_default_true")] + pub password_registration_email_required: bool, + /// Whether users are allowed to change their passwords. Defaults to `true`. /// /// This has no effect if password login is disabled. @@ -89,6 +96,7 @@ impl Default for AccountConfig { email_change_allowed: default_true(), displayname_change_allowed: default_true(), password_registration_enabled: default_false(), + password_registration_email_required: default_true(), password_change_allowed: default_true(), password_recovery_enabled: default_false(), account_deactivation_allowed: default_true(), diff --git a/crates/context/src/fmt.rs b/crates/context/src/fmt.rs index 25908a2ca..f4c4981e1 100644 --- a/crates/context/src/fmt.rs +++ b/crates/context/src/fmt.rs @@ -4,10 +4,7 @@ // Please see LICENSE files in the repository root for full details. use console::{Color, Style}; -use opentelemetry::{ - TraceId, - trace::{SamplingDecision, TraceContextExt}, -}; +use opentelemetry::TraceId; use tracing::{Level, Subscriber}; use tracing_opentelemetry::OtelData; use tracing_subscriber::{ @@ -131,31 +128,14 @@ where // If we have a OTEL span, we can add the trace ID to the end of the log line if let Some(span) = ctx.lookup_current() && let Some(otel) = span.extensions().get::() + && let Some(trace_id) = otel.trace_id() + && trace_id != TraceId::INVALID { - let parent_cx_span = otel.parent_cx.span(); - let sc = parent_cx_span.span_context(); - - // Check if the span is sampled, first from the span builder, - // then from the parent context if nothing is set there - if otel - .builder - .sampling_result - .as_ref() - .map_or(sc.is_sampled(), |r| { - r.decision == SamplingDecision::RecordAndSample - }) - { - // If it is the root span, the trace ID will be in the span builder. Else, it - // will be in the parent OTEL context - let trace_id = otel.builder.trace_id.unwrap_or(sc.trace_id()); - if trace_id != TraceId::INVALID { - let label = Style::new() - .italic() - .force_styling(ansi) - .apply_to("trace.id"); - write!(&mut writer, " {label}={trace_id}")?; - } - } + let label = Style::new() + .italic() + .force_styling(ansi) + .apply_to("trace.id"); + write!(&mut writer, " {label}={trace_id}")?; } writeln!(&mut writer) diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index e16b1dc25..8738ae9cf 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -11,6 +11,7 @@ use thiserror::Error; pub mod clock; pub(crate) mod compat; pub mod oauth2; +pub mod personal; pub(crate) mod policy_data; mod site_config; //:tchap: @@ -21,6 +22,7 @@ pub(crate) mod upstream_oauth2; pub(crate) mod user_agent; pub(crate) mod users; mod utils; +mod version; /// Error when an invalid state transition is attempted. #[derive(Debug, Error)] @@ -63,4 +65,5 @@ pub use self::{ UserRecoveryTicket, UserRegistration, UserRegistrationPassword, UserRegistrationToken, }, utils::{BoxClock, BoxRng}, + version::AppVersion, }; diff --git a/crates/data-model/src/personal/mod.rs b/crates/data-model/src/personal/mod.rs new file mode 100644 index 000000000..1142fea76 --- /dev/null +++ b/crates/data-model/src/personal/mod.rs @@ -0,0 +1,32 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +pub mod session; + +use chrono::{DateTime, Utc}; +use ulid::Ulid; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersonalAccessToken { + pub id: Ulid, + pub session_id: Ulid, + pub created_at: DateTime, + pub expires_at: Option>, + pub revoked_at: Option>, +} + +impl PersonalAccessToken { + #[must_use] + pub fn is_valid(&self, now: DateTime) -> bool { + if self.revoked_at.is_some() { + return false; + } + if let Some(expires_at) = self.expires_at { + expires_at > now + } else { + true + } + } +} diff --git a/crates/data-model/src/personal/session.rs b/crates/data-model/src/personal/session.rs new file mode 100644 index 000000000..f3c8d34f9 --- /dev/null +++ b/crates/data-model/src/personal/session.rs @@ -0,0 +1,141 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use std::net::IpAddr; + +use chrono::{DateTime, Utc}; +use oauth2_types::scope::Scope; +use serde::Serialize; +use ulid::Ulid; + +use crate::{Client, Device, InvalidTransitionError, User}; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub enum SessionState { + #[default] + Valid, + Revoked { + revoked_at: DateTime, + }, +} + +impl SessionState { + /// Returns `true` if the session state is [`Valid`]. + /// + /// [`Valid`]: SessionState::Valid + #[must_use] + pub fn is_valid(&self) -> bool { + matches!(self, Self::Valid) + } + + /// Returns `true` if the session state is [`Revoked`]. + /// + /// [`Revoked`]: SessionState::Revoked + #[must_use] + pub fn is_revoked(&self) -> bool { + matches!(self, Self::Revoked { .. }) + } + + /// Transitions the session state to [`Revoked`]. + /// + /// # Parameters + /// + /// * `revoked_at` - The time at which the session was revoked. + /// + /// # Errors + /// + /// Returns an error if the session state is already [`Revoked`]. + /// + /// [`Revoked`]: SessionState::Revoked + pub fn revoke(self, revoked_at: DateTime) -> Result { + match self { + Self::Valid => Ok(Self::Revoked { revoked_at }), + Self::Revoked { .. } => Err(InvalidTransitionError), + } + } + + /// Returns the time the session was revoked, if any + /// + /// Returns `None` if the session is still [`Valid`]. + /// + /// [`Valid`]: SessionState::Valid + #[must_use] + pub fn revoked_at(&self) -> Option> { + match self { + Self::Valid => None, + Self::Revoked { revoked_at } => Some(*revoked_at), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PersonalSession { + pub id: Ulid, + pub state: SessionState, + pub owner: PersonalSessionOwner, + pub actor_user_id: Ulid, + pub human_name: String, + /// The scope for the session, identical to OAuth 2 sessions. + /// May or may not include a device scope + /// (personal sessions can be deviceless). + pub scope: Scope, + pub created_at: DateTime, + pub last_active_at: Option>, + pub last_active_ip: Option, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize)] +pub enum PersonalSessionOwner { + /// The personal session is owned by the user with the given `user_id`. + User(Ulid), + /// The personal session is owned by the OAuth 2 Client with the given + /// `oauth2_client_id`. + OAuth2Client(Ulid), +} + +impl<'a> From<&'a User> for PersonalSessionOwner { + fn from(value: &'a User) -> Self { + PersonalSessionOwner::User(value.id) + } +} + +impl<'a> From<&'a Client> for PersonalSessionOwner { + fn from(value: &'a Client) -> Self { + PersonalSessionOwner::OAuth2Client(value.id) + } +} + +impl std::ops::Deref for PersonalSession { + type Target = SessionState; + + fn deref(&self) -> &Self::Target { + &self.state + } +} + +impl PersonalSession { + /// Marks the session as revoked. + /// + /// # Parameters + /// + /// * `revoked_at` - The time at which the session was finished. + /// + /// # Errors + /// + /// Returns an error if the session is already finished. + pub fn finish(mut self, revoked_at: DateTime) -> Result { + self.state = self.state.revoke(revoked_at)?; + Ok(self) + } + + /// Returns whether the scope of this session contains a device scope; + /// in other words: whether this session has a device. + #[must_use] + pub fn has_device(&self) -> bool { + self.scope + .iter() + .any(|scope_token| Device::from_scope_token(scope_token).is_some()) + } +} diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index ac0d7e6b8..9622203ad 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -64,6 +64,9 @@ pub struct SiteConfig { /// Whether password registration is enabled. pub password_registration_enabled: bool, + /// Whether a valid email address is required for password registrations. + pub password_registration_email_required: bool, + /// Whether registration tokens are required for password registrations. pub registration_token_required: bool, diff --git a/crates/data-model/src/tokens.rs b/crates/data-model/src/tokens.rs index 1ea5be6be..bd34c5000 100644 --- a/crates/data-model/src/tokens.rs +++ b/crates/data-model/src/tokens.rs @@ -240,6 +240,9 @@ pub enum TokenType { /// A legacy refresh token CompatRefreshToken, + + /// A personal access token. + PersonalAccessToken, } impl std::fmt::Display for TokenType { @@ -249,6 +252,7 @@ impl std::fmt::Display for TokenType { TokenType::RefreshToken => write!(f, "refresh token"), TokenType::CompatAccessToken => write!(f, "compat access token"), TokenType::CompatRefreshToken => write!(f, "compat refresh token"), + TokenType::PersonalAccessToken => write!(f, "personal access token"), } } } @@ -260,6 +264,7 @@ impl TokenType { TokenType::RefreshToken => "mar", TokenType::CompatAccessToken => "mct", TokenType::CompatRefreshToken => "mcr", + TokenType::PersonalAccessToken => "mpt", } } @@ -269,6 +274,7 @@ impl TokenType { "mar" => Some(TokenType::RefreshToken), "mct" | "syt" => Some(TokenType::CompatAccessToken), "mcr" | "syr" => Some(TokenType::CompatRefreshToken), + "mpt" => Some(TokenType::PersonalAccessToken), _ => None, } } @@ -335,7 +341,9 @@ impl PartialEq for TokenType { matches!( (self, other), ( - TokenType::AccessToken | TokenType::CompatAccessToken, + TokenType::AccessToken + | TokenType::CompatAccessToken + | TokenType::PersonalAccessToken, OAuthTokenTypeHint::AccessToken ) | ( TokenType::RefreshToken | TokenType::CompatRefreshToken, diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs index 5221f4867..7c7da6293 100644 --- a/crates/data-model/src/users.rs +++ b/crates/data-model/src/users.rs @@ -30,6 +30,20 @@ impl User { pub fn is_valid(&self) -> bool { self.locked_at.is_none() && self.deactivated_at.is_none() } + + /// Returns `true` if the user is a valid actor, for example + /// of a personal session. + /// + /// Currently: this is `true` unless the user is deactivated. + /// + /// This is a weaker form of validity: `is_valid` always implies + /// `is_valid_actor`, but some users (currently: locked users) + /// can be valid actors for personal sessions but aren't valid + /// except through administrative access. + #[must_use] + pub fn is_valid_actor(&self) -> bool { + self.deactivated_at.is_none() + } } impl User { diff --git a/crates/data-model/src/version.rs b/crates/data-model/src/version.rs new file mode 100644 index 000000000..86d890fc1 --- /dev/null +++ b/crates/data-model/src/version.rs @@ -0,0 +1,8 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +/// A structure which holds information about the running version of the app +#[derive(Debug, Clone, Copy)] +pub struct AppVersion(pub &'static str); diff --git a/crates/email/src/transport.rs b/crates/email/src/transport.rs index 9161d76e5..004844ab1 100644 --- a/crates/email/src/transport.rs +++ b/crates/email/src/transport.rs @@ -36,7 +36,9 @@ pub struct Transport { inner: Arc, } +#[derive(Default)] enum TransportInner { + #[default] Blackhole, Smtp(AsyncSmtpTransport), Sendmail(AsyncSendmailTransport), @@ -113,12 +115,6 @@ impl Transport { } } -impl Default for TransportInner { - fn default() -> Self { - Self::Blackhole - } -} - #[derive(Debug, Error)] #[error(transparent)] pub enum Error { diff --git a/crates/handlers/src/activity_tracker/bound.rs b/crates/handlers/src/activity_tracker/bound.rs index 14d36fb7c..8f7acbdde 100644 --- a/crates/handlers/src/activity_tracker/bound.rs +++ b/crates/handlers/src/activity_tracker/bound.rs @@ -6,7 +6,9 @@ use std::net::IpAddr; -use mas_data_model::{BrowserSession, Clock, CompatSession, Session}; +use mas_data_model::{ + BrowserSession, Clock, CompatSession, Session, personal::session::PersonalSession, +}; use crate::activity_tracker::ActivityTracker; @@ -37,6 +39,13 @@ impl Bound { .await; } + /// Record activity in a personal session. + pub async fn record_personal_session(&self, clock: &dyn Clock, session: &PersonalSession) { + self.tracker + .record_personal_session(clock, session, self.ip) + .await; + } + /// Record activity in a compatibility session. pub async fn record_compat_session(&self, clock: &dyn Clock, session: &CompatSession) { self.tracker diff --git a/crates/handlers/src/activity_tracker/mod.rs b/crates/handlers/src/activity_tracker/mod.rs index 738da3856..e1c6b976f 100644 --- a/crates/handlers/src/activity_tracker/mod.rs +++ b/crates/handlers/src/activity_tracker/mod.rs @@ -10,7 +10,9 @@ mod worker; use std::net::IpAddr; use chrono::{DateTime, Utc}; -use mas_data_model::{BrowserSession, Clock, CompatSession, Session}; +use mas_data_model::{ + BrowserSession, Clock, CompatSession, Session, personal::session::PersonalSession, +}; use mas_storage::BoxRepositoryFactory; use tokio_util::{sync::CancellationToken, task::TaskTracker}; use ulid::Ulid; @@ -24,6 +26,8 @@ static MESSAGE_QUEUE_SIZE: usize = 1000; enum SessionKind { OAuth2, Compat, + /// Session associated with personal access tokens + Personal, Browser, } @@ -32,6 +36,7 @@ impl SessionKind { match self { SessionKind::OAuth2 => "oauth2", SessionKind::Compat => "compat", + SessionKind::Personal => "personal", SessionKind::Browser => "browser", } } @@ -108,6 +113,28 @@ impl ActivityTracker { } } + /// Record activity in a personal session. + pub async fn record_personal_session( + &self, + clock: &dyn Clock, + session: &PersonalSession, + ip: Option, + ) { + let res = self + .channel + .send(Message::Record { + kind: SessionKind::Personal, + id: session.id, + date_time: clock.now(), + ip, + }) + .await; + + if let Err(e) = res { + tracing::error!("Failed to record Personal session: {}", e); + } + } + /// Record activity in a compat session. pub async fn record_compat_session( &self, diff --git a/crates/handlers/src/activity_tracker/worker.rs b/crates/handlers/src/activity_tracker/worker.rs index 46cc84ccd..9405eab41 100644 --- a/crates/handlers/src/activity_tracker/worker.rs +++ b/crates/handlers/src/activity_tracker/worker.rs @@ -224,6 +224,7 @@ impl Worker { let mut browser_sessions = Vec::new(); let mut oauth2_sessions = Vec::new(); let mut compat_sessions = Vec::new(); + let mut personal_sessions = Vec::new(); for ((kind, id), record) in pending_records { match kind { @@ -236,6 +237,9 @@ impl Worker { SessionKind::Compat => { compat_sessions.push((*id, record.end_time, record.ip)); } + SessionKind::Personal => { + personal_sessions.push((*id, record.end_time, record.ip)); + } } } @@ -253,6 +257,9 @@ impl Worker { repo.compat_session() .record_batch_activity(compat_sessions) .await?; + repo.personal_session() + .record_batch_activity(personal_sessions) + .await?; repo.save().await?; self.pending_records.clear(); diff --git a/crates/handlers/src/admin/call_context.rs b/crates/handlers/src/admin/call_context.rs index ebe0e02e5..1cffe682e 100644 --- a/crates/handlers/src/admin/call_context.rs +++ b/crates/handlers/src/admin/call_context.rs @@ -16,8 +16,12 @@ use axum_extra::TypedHeader; use headers::{Authorization, authorization::Bearer}; use hyper::StatusCode; use mas_axum_utils::record_error; -use mas_data_model::{BoxClock, Session, User}; +use mas_data_model::{ + BoxClock, Session, TokenFormatError, TokenType, User, + personal::session::{PersonalSession, PersonalSessionOwner}, +}; use mas_storage::{BoxRepository, RepositoryError}; +use oauth2_types::scope::Scope; use ulid::Ulid; use super::response::ErrorResponse; @@ -41,6 +45,10 @@ pub enum Rejection { #[error("Invalid repository operation")] Repository(#[from] RepositoryError), + /// The access token was not of the correct type for the Admin API + #[error("Invalid type of access token")] + InvalidAccessTokenType(#[from] Option), + /// The access token could not be found in the database #[error("Unknown access token")] UnknownAccessToken, @@ -90,7 +98,8 @@ impl IntoResponse for Rejection { | Rejection::TokenExpired | Rejection::SessionRevoked | Rejection::UserLocked - | Rejection::MissingScope => StatusCode::UNAUTHORIZED, + | Rejection::MissingScope + | Rejection::InvalidAccessTokenType(_) => StatusCode::UNAUTHORIZED, Rejection::RepositorySetup(_) | Rejection::Repository(_) @@ -113,7 +122,7 @@ pub struct CallContext { pub repo: BoxRepository, pub clock: BoxClock, pub user: Option, - pub session: Session, + pub session: CallerSession, } impl FromRequestParts for CallContext @@ -154,56 +163,126 @@ where })?; let token = token.token(); + let token_type = TokenType::check(token)?; + + let session = match token_type { + TokenType::AccessToken => { + // Look for the access token in the database + let token = repo + .oauth2_access_token() + .find_by_token(token) + .await? + .ok_or(Rejection::UnknownAccessToken)?; + + // Look for the associated session in the database + let session = repo + .oauth2_session() + .lookup(token.session_id) + .await? + .ok_or_else(|| Rejection::LoadSession(token.session_id))?; + + if !session.is_valid() { + return Err(Rejection::SessionRevoked); + } + + if !token.is_valid(clock.now()) { + return Err(Rejection::TokenExpired); + } - // Look for the access token in the database - let token = repo - .oauth2_access_token() - .find_by_token(token) - .await? - .ok_or(Rejection::UnknownAccessToken)?; - - // Look for the associated session in the database - let session = repo - .oauth2_session() - .lookup(token.session_id) - .await? - .ok_or_else(|| Rejection::LoadSession(token.session_id))?; - - // Record the activity on the session - activity_tracker - .record_oauth2_session(&clock, &session) - .await; + // Record the activity on the session + activity_tracker + .record_oauth2_session(&clock, &session) + .await; + + CallerSession::OAuth2Session(session) + } + TokenType::PersonalAccessToken => { + // Look for the access token in the database + let token = repo + .personal_access_token() + .find_by_token(token) + .await? + .ok_or(Rejection::UnknownAccessToken)?; + + // Look for the associated session in the database + let session = repo + .personal_session() + .lookup(token.session_id) + .await? + .ok_or_else(|| Rejection::LoadSession(token.session_id))?; + + if !session.is_valid() { + return Err(Rejection::SessionRevoked); + } + + if !token.is_valid(clock.now()) { + return Err(Rejection::TokenExpired); + } + + // Check the validity of the owner of the personal session + match session.owner { + PersonalSessionOwner::User(owner_user_id) => { + let owner_user = repo + .user() + .lookup(owner_user_id) + .await? + .ok_or_else(|| Rejection::LoadUser(owner_user_id))?; + if !owner_user.is_valid() { + return Err(Rejection::UserLocked); + } + } + PersonalSessionOwner::OAuth2Client(_) => { + // nop: Client owners are always valid + } + } + + // Record the activity on the session + activity_tracker + .record_personal_session(&clock, &session) + .await; + + CallerSession::PersonalSession(session) + } + _other => { + return Err(Rejection::InvalidAccessTokenType(None)); + } + }; // Load the user if there is one - let user = if let Some(user_id) = session.user_id { + let user = if let Some(user_id) = session.user_id() { let user = repo .user() .lookup(user_id) .await? .ok_or_else(|| Rejection::LoadUser(user_id))?; + + match session { + CallerSession::OAuth2Session(_) => { + // For OAuth2 sessions: check that the user is valid enough + // to be a user. + if !user.is_valid() { + return Err(Rejection::UserLocked); + } + } + CallerSession::PersonalSession(_) => { + // For personal sessions: check that the actor is valid enough + // to be an actor. + if !user.is_valid_actor() { + return Err(Rejection::UserLocked); + } + } + } + Some(user) } else { + // Double check we're not using a PersonalSession + assert!(matches!(session, CallerSession::OAuth2Session(_))); None }; - // If there is a user for this session, check that it is not locked - if let Some(user) = &user - && !user.is_valid() - { - return Err(Rejection::UserLocked); - } - - if !session.is_valid() { - return Err(Rejection::SessionRevoked); - } - - if !token.is_valid(clock.now()) { - return Err(Rejection::TokenExpired); - } - // For now, we only check that the session has the admin scope // Later we might want to check other route-specific scopes - if !session.scope.contains("urn:mas:admin") { + if !session.scope().contains("urn:mas:admin") { return Err(Rejection::MissingScope); } @@ -215,3 +294,26 @@ where }) } } + +/// The session representing the caller of the Admin API; +/// could either be an OAuth session or a personal session. +pub enum CallerSession { + OAuth2Session(Session), + PersonalSession(PersonalSession), +} + +impl CallerSession { + pub fn scope(&self) -> &Scope { + match self { + CallerSession::OAuth2Session(session) => &session.scope, + CallerSession::PersonalSession(session) => &session.scope, + } + } + + pub fn user_id(&self) -> Option { + match self { + CallerSession::OAuth2Session(session) => session.user_id, + CallerSession::PersonalSession(session) => Some(session.actor_user_id), + } + } +} diff --git a/crates/handlers/src/admin/mod.rs b/crates/handlers/src/admin/mod.rs index e5e158be3..ec0928a13 100644 --- a/crates/handlers/src/admin/mod.rs +++ b/crates/handlers/src/admin/mod.rs @@ -20,7 +20,11 @@ use axum::{ use hyper::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; use indexmap::IndexMap; use mas_axum_utils::InternalError; +<<<<<<< HEAD use mas_data_model::{BoxRng, SiteConfig}; +======= +use mas_data_model::{AppVersion, BoxRng, SiteConfig}; +>>>>>>> v1.6.0 use mas_http::CorsLayerExt; use mas_matrix::HomeserverConnection; use mas_policy::PolicyFactory; @@ -164,6 +168,10 @@ where UrlBuilder: FromRef, Arc: FromRef, SiteConfig: FromRef, +<<<<<<< HEAD +======= + AppVersion: FromRef, +>>>>>>> v1.6.0 { // We *always* want to explicitly set the possible responses, beacuse the // infered ones are not necessarily correct diff --git a/crates/handlers/src/admin/model.rs b/crates/handlers/src/admin/model.rs index c21e22fd7..09208978a 100644 --- a/crates/handlers/src/admin/model.rs +++ b/crates/handlers/src/admin/model.rs @@ -7,9 +7,16 @@ use std::net::IpAddr; use chrono::{DateTime, Utc}; -use mas_data_model::Device; +use mas_data_model::{ + Device, + personal::{ + PersonalAccessToken as DataModelPersonalAccessToken, + session::{PersonalSession as DataModelPersonalSession, PersonalSessionOwner}, + }, +}; use schemars::JsonSchema; use serde::Serialize; +use thiserror::Error; use ulid::Ulid; use url::Url; @@ -771,3 +778,182 @@ impl UpstreamOAuthProvider { ] } } +<<<<<<< HEAD +======= + +/// An error that shouldn't happen in practice, but suggests database +/// inconsistency. +#[derive(Debug, Error)] +#[error( + "personal session {session_id} in inconsistent state: not revoked but no valid access token" +)] +pub struct InconsistentPersonalSession { + pub session_id: Ulid, +} + +// Note: we don't expose a separate concept of personal access tokens to the +// admin API; we merge the relevant attributes into the personal session. +/// A personal session (session using personal access tokens) +#[derive(Serialize, JsonSchema)] +pub struct PersonalSession { + #[serde(skip)] + id: Ulid, + + /// When the session was created + created_at: DateTime, + + /// When the session was revoked, if applicable + revoked_at: Option>, + + /// The ID of the user who owns this session (if user-owned) + #[schemars(with = "Option")] + owner_user_id: Option, + + /// The ID of the `OAuth2` client that owns this session (if client-owned) + #[schemars(with = "Option")] + owner_client_id: Option, + + /// The ID of the user that the session acts on behalf of + #[schemars(with = "super::schema::Ulid")] + actor_user_id: Ulid, + + /// Human-readable name for the session + human_name: String, + + /// `OAuth2` scopes for this session + scope: String, + + /// When the session was last active + last_active_at: Option>, + + /// IP address of last activity + last_active_ip: Option, + + /// When the current token for this session expires. + /// The session will need to be regenerated, producing a new access token, + /// after this time. + /// None if the current token won't expire or if the session is revoked. + expires_at: Option>, + + /// The actual access token (only returned on creation) + #[serde(skip_serializing_if = "Option::is_none")] + access_token: Option, +} + +impl + TryFrom<( + DataModelPersonalSession, + Option, + )> for PersonalSession +{ + type Error = InconsistentPersonalSession; + + fn try_from( + (session, token): ( + DataModelPersonalSession, + Option, + ), + ) -> Result { + let expires_at = if let Some(token) = token { + token.expires_at + } else { + if !session.is_revoked() { + // No active token, but the session is not revoked. + return Err(InconsistentPersonalSession { + session_id: session.id, + }); + } + None + }; + + let (owner_user_id, owner_client_id) = match session.owner { + PersonalSessionOwner::User(id) => (Some(id), None), + PersonalSessionOwner::OAuth2Client(id) => (None, Some(id)), + }; + + Ok(Self { + id: session.id, + created_at: session.created_at, + revoked_at: session.revoked_at(), + owner_user_id, + owner_client_id, + actor_user_id: session.actor_user_id, + human_name: session.human_name, + scope: session.scope.to_string(), + last_active_at: session.last_active_at, + last_active_ip: session.last_active_ip, + expires_at, + // If relevant, the caller will populate using `with_token` afterwards. + access_token: None, + }) + } +} + +impl Resource for PersonalSession { + const KIND: &'static str = "personal-session"; + const PATH: &'static str = "/api/admin/v1/personal-sessions"; + + fn id(&self) -> Ulid { + self.id + } +} + +impl PersonalSession { + /// Sample personal sessions for documentation/testing + pub fn samples() -> [Self; 3] { + [ + Self { + id: Ulid::from_string("01FSHN9AG0AJ6AC5HQ9X6H4RP4").unwrap(), + created_at: DateTime::from_timestamp(1_642_338_000, 0).unwrap(), /* 2022-01-16T14: + * 40:00Z */ + revoked_at: None, + owner_user_id: Some(Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap()), + owner_client_id: None, + actor_user_id: Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap(), + human_name: "Alice's Development Token".to_owned(), + scope: "openid urn:matrix:org.matrix.msc2967.client:api:*".to_owned(), + last_active_at: Some(DateTime::from_timestamp(1_642_347_000, 0).unwrap()), /* 2022-01-16T17:10:00Z */ + last_active_ip: Some("192.168.1.100".parse().unwrap()), + expires_at: None, + access_token: None, + }, + Self { + id: Ulid::from_string("01FSHN9AG0BJ6AC5HQ9X6H4RP5").unwrap(), + created_at: DateTime::from_timestamp(1_642_338_060, 0).unwrap(), /* 2022-01-16T14: + * 41:00Z */ + revoked_at: Some(DateTime::from_timestamp(1_642_350_000, 0).unwrap()), /* 2022-01-16T18:00:00Z */ + owner_user_id: Some(Ulid::from_string("01FSHN9AG0NZAA6S4AF7CTV32F").unwrap()), + owner_client_id: None, + actor_user_id: Ulid::from_string("01FSHN9AG0NZAA6S4AF7CTV32F").unwrap(), + human_name: "Bob's Mobile App".to_owned(), + scope: "openid".to_owned(), + last_active_at: Some(DateTime::from_timestamp(1_642_349_000, 0).unwrap()), /* 2022-01-16T17:43:20Z */ + last_active_ip: Some("10.0.0.50".parse().unwrap()), + expires_at: None, + access_token: None, + }, + Self { + id: Ulid::from_string("01FSHN9AG0CJ6AC5HQ9X6H4RP6").unwrap(), + created_at: DateTime::from_timestamp(1_642_338_120, 0).unwrap(), /* 2022-01-16T14: + * 42:00Z */ + revoked_at: None, + owner_user_id: None, + owner_client_id: Some(Ulid::from_string("01FSHN9AG0DJ6AC5HQ9X6H4RP7").unwrap()), + actor_user_id: Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap(), + human_name: "CI/CD Pipeline Token".to_owned(), + scope: "openid urn:mas:admin".to_owned(), + last_active_at: Some(DateTime::from_timestamp(1_642_348_000, 0).unwrap()), /* 2022-01-16T17:26:40Z */ + last_active_ip: Some("203.0.113.10".parse().unwrap()), + expires_at: Some(DateTime::from_timestamp(1_642_999_000, 0).unwrap()), + access_token: None, + }, + ] + } + + /// Add the actual token value (for use in creation responses) + pub fn with_token(mut self, access_token: String) -> Self { + self.access_token = Some(access_token); + self + } +} +>>>>>>> v1.6.0 diff --git a/crates/handlers/src/admin/params.rs b/crates/handlers/src/admin/params.rs index 633917d9a..4b1ccb1de 100644 --- a/crates/handlers/src/admin/params.rs +++ b/crates/handlers/src/admin/params.rs @@ -7,17 +7,15 @@ // Generated code from schemars violates this rule #![allow(clippy::str_to_string)] -use std::num::NonZeroUsize; +use std::{borrow::Cow, num::NonZeroUsize}; use aide::OperationIo; use axum::{ Json, - extract::{ - FromRequestParts, Path, Query, - rejection::{PathRejection, QueryRejection}, - }, + extract::{FromRequestParts, Path, rejection::PathRejection}, response::IntoResponse, }; +use axum_extra::extract::{Query, QueryRejection}; use axum_macros::FromRequestParts; use hyper::StatusCode; use mas_storage::pagination::PaginationDirection; @@ -64,6 +62,34 @@ impl std::ops::Deref for UlidPathParam { /// The default page size if not specified const DEFAULT_PAGE_SIZE: usize = 10; +#[derive(Deserialize, JsonSchema, Clone, Copy, Default, Debug)] +pub enum IncludeCount { + /// Include the total number of items (default) + #[default] + #[serde(rename = "true")] + True, + + /// Do not include the total number of items + #[serde(rename = "false")] + False, + + /// Only include the total number of items, skip the items themselves + #[serde(rename = "only")] + Only, +} + +impl IncludeCount { + pub(crate) fn add_to_base(self, base: &str) -> Cow<'_, str> { + let separator = if base.contains('?') { '&' } else { '?' }; + match self { + // This is the default, don't add anything + Self::True => Cow::Borrowed(base), + Self::False => format!("{base}{separator}count=false").into(), + Self::Only => format!("{base}{separator}count=only").into(), + } + } +} + #[derive(Deserialize, JsonSchema, Clone, Copy)] struct PaginationParams { /// Retrieve the items before the given ID @@ -83,6 +109,10 @@ struct PaginationParams { /// Retrieve the last N items #[serde(rename = "page[last]")] last: Option, + + /// Include the total number of items. Defaults to `true`. + #[serde(rename = "count")] + include_count: Option, } #[derive(Debug, thiserror::Error)] @@ -107,7 +137,7 @@ impl IntoResponse for PaginationRejection { /// An extractor for pagination parameters in the query string #[derive(OperationIo, Debug, Clone, Copy)] #[aide(input_with = "Query")] -pub struct Pagination(pub mas_storage::Pagination); +pub struct Pagination(pub mas_storage::Pagination, pub IncludeCount); impl FromRequestParts for Pagination { type Rejection = PaginationRejection; @@ -130,11 +160,14 @@ impl FromRequestParts for Pagination { (None, Some(last)) => (PaginationDirection::Backward, last.into()), }; - Ok(Self(mas_storage::Pagination { - before: params.before, - after: params.after, - direction, - count, - })) + Ok(Self( + mas_storage::Pagination { + before: params.before, + after: params.after, + direction, + count, + }, + params.include_count.unwrap_or_default(), + )) } } diff --git a/crates/handlers/src/admin/response.rs b/crates/handlers/src/admin/response.rs index 19f0e8040..257773cd2 100644 --- a/crates/handlers/src/admin/response.rs +++ b/crates/handlers/src/admin/response.rs @@ -6,7 +6,7 @@ #![allow(clippy::module_name_repetitions)] -use mas_storage::Pagination; +use mas_storage::{Pagination, pagination::Edge}; use schemars::JsonSchema; use serde::Serialize; use ulid::Ulid; @@ -21,10 +21,12 @@ struct PaginationLinks { self_: String, /// The link to the first page of results - first: String, + #[serde(skip_serializing_if = "Option::is_none")] + first: Option, /// The link to the last page of results - last: String, + #[serde(skip_serializing_if = "Option::is_none")] + last: Option, /// The link to the next page of results /// @@ -42,17 +44,27 @@ struct PaginationLinks { #[derive(Serialize, JsonSchema)] struct PaginationMeta { /// The total number of results - count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + count: Option, +} + +impl PaginationMeta { + fn is_empty(&self) -> bool { + self.count.is_none() + } } /// A top-level response with a page of resources #[derive(Serialize, JsonSchema)] pub struct PaginatedResponse { /// Response metadata + #[serde(skip_serializing_if = "PaginationMeta::is_empty")] + #[schemars(with = "Option")] meta: PaginationMeta, /// The list of resources - data: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option>>, /// Related links links: PaginationLinks, @@ -87,22 +99,28 @@ fn url_with_pagination(base: &str, pagination: Pagination) -> String { } impl PaginatedResponse { - pub fn new( + pub fn for_page( page: mas_storage::Page, current_pagination: Pagination, - count: usize, + count: Option, base: &str, ) -> Self { let links = PaginationLinks { self_: url_with_pagination(base, current_pagination), - first: url_with_pagination(base, Pagination::first(current_pagination.count)), - last: url_with_pagination(base, Pagination::last(current_pagination.count)), + first: Some(url_with_pagination( + base, + Pagination::first(current_pagination.count), + )), + last: Some(url_with_pagination( + base, + Pagination::last(current_pagination.count), + )), next: page.has_next_page.then(|| { url_with_pagination( base, current_pagination .clear_before() - .after(page.edges.last().unwrap().id()), + .after(page.edges.last().unwrap().cursor), ) }), prev: if page.has_previous_page { @@ -110,18 +128,38 @@ impl PaginatedResponse { base, current_pagination .clear_after() - .before(page.edges.first().unwrap().id()), + .before(page.edges.first().unwrap().cursor), )) } else { None }, }; - let data = page.edges.into_iter().map(SingleResource::new).collect(); + let data = page + .edges + .into_iter() + .map(SingleResource::from_edge) + .collect(); Self { meta: PaginationMeta { count }, - data, + data: Some(data), + links, + } + } + + pub fn for_count_only(count: usize, base: &str) -> Self { + let links = PaginationLinks { + self_: base.to_owned(), + first: None, + last: None, + next: None, + prev: None, + }; + + Self { + meta: PaginationMeta { count: Some(count) }, + data: None, links, } } @@ -143,6 +181,32 @@ struct SingleResource { /// Related links links: SelfLinks, + + /// Metadata about the resource + #[serde(skip_serializing_if = "SingleResourceMeta::is_empty")] + #[schemars(with = "Option")] + meta: SingleResourceMeta, +} + +/// Metadata associated with a resource +#[derive(Serialize, JsonSchema)] +struct SingleResourceMeta { + /// Information about the pagination of the resource + #[serde(skip_serializing_if = "Option::is_none")] + page: Option, +} + +impl SingleResourceMeta { + fn is_empty(&self) -> bool { + self.page.is_none() + } +} + +/// Pagination metadata for a resource +#[derive(Serialize, JsonSchema)] +struct SingleResourceMetaPage { + /// The cursor of this resource in the paginated result + cursor: String, } impl SingleResource { @@ -153,8 +217,16 @@ impl SingleResource { id: resource.id(), attributes: resource, links: SelfLinks { self_ }, + meta: SingleResourceMeta { page: None }, } } + + fn from_edge(edge: Edge) -> Self { + let cursor = edge.cursor.to_string(); + let mut resource = Self::new(edge.node); + resource.meta.page = Some(SingleResourceMetaPage { cursor }); + resource + } } /// Related links diff --git a/crates/handlers/src/admin/v1/compat_sessions/finish.rs b/crates/handlers/src/admin/v1/compat_sessions/finish.rs new file mode 100644 index 000000000..df42c2ff9 --- /dev/null +++ b/crates/handlers/src/admin/v1/compat_sessions/finish.rs @@ -0,0 +1,243 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{NoApi, OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_data_model::BoxRng; +use mas_storage::queue::{QueueJobRepositoryExt as _, SyncDevicesJob}; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{CompatSession, Resource}, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Compatibility session with ID {0} not found")] + NotFound(Ulid), + + #[error("Compatibility session with ID {0} is already finished")] + AlreadyFinished(Ulid), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound(_) => StatusCode::NOT_FOUND, + Self::AlreadyFinished(_) => StatusCode::BAD_REQUEST, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("finishCompatSession") + .summary("Finish a compatibility session") + .description( + "Calling this endpoint will finish the compatibility session, preventing any further use. A job will be scheduled to sync the user's devices with the homeserver.", + ) + .tag("compat-session") + .response_with::<200, Json>, _>(|t| { + // Get the finished session sample + let [_, finished_session, _] = CompatSession::samples(); + let id = finished_session.id(); + let response = SingleResponse::new( + finished_session, + format!("/api/admin/v1/compat-sessions/{id}/finish"), + ); + t.description("Compatibility session was finished").example(response) + }) + .response_with::<400, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::AlreadyFinished(Ulid::nil())); + t.description("Session is already finished") + .example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("Compatibility session was not found") + .example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.compat_sessions.finish", skip_all)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + NoApi(mut rng): NoApi, + id: UlidPathParam, +) -> Result>, RouteError> { + let id = *id; + let session = repo + .compat_session() + .lookup(id) + .await? + .ok_or(RouteError::NotFound(id))?; + + // Check if the session is already finished + if session.finished_at().is_some() { + return Err(RouteError::AlreadyFinished(id)); + } + + // Schedule a job to sync the devices of the user with the homeserver + tracing::info!(user.id = %session.user_id, "Scheduling device sync job for user"); + repo.queue_job() + .schedule_job( + &mut rng, + &clock, + SyncDevicesJob::new_for_id(session.user_id), + ) + .await?; + + // Finish the session + let session = repo.compat_session().finish(&clock, session).await?; + + // Get the SSO login info for the response + let sso_login = repo.compat_sso_login().find_for_session(&session).await?; + + repo.save().await?; + + Ok(Json(SingleResponse::new( + CompatSession::from((session, sso_login)), + format!("/api/admin/v1/compat-sessions/{id}/finish"), + ))) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode}; + use mas_data_model::{Clock as _, Device}; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + // Provision a user and a compat session + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let device = Device::generate(&mut rng); + let session = repo + .compat_session() + .add(&mut rng, &state.clock, &user, device, None, false, None) + .await + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::post(format!( + "/api/admin/v1/compat-sessions/{}/finish", + session.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // The finished_at timestamp should be the same as the current time + assert_eq!( + body["data"]["attributes"]["finished_at"], + serde_json::json!(state.clock.now()) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_already_finished_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + // Provision a user and a compat session + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let device = Device::generate(&mut rng); + let session = repo + .compat_session() + .add(&mut rng, &state.clock, &user, device, None, false, None) + .await + .unwrap(); + + // Finish the session first + let session = repo + .compat_session() + .finish(&state.clock, session) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Move the clock forward + state.clock.advance(Duration::try_minutes(1).unwrap()); + + let request = Request::post(format!( + "/api/admin/v1/compat-sessions/{}/finish", + session.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + format!( + "Compatibility session with ID {} is already finished", + session.id + ) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_unknown_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = + Request::post("/api/admin/v1/compat-sessions/01040G2081040G2081040G2081/finish") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + "Compatibility session with ID 01040G2081040G2081040G2081 not found" + ); + } +} diff --git a/crates/handlers/src/admin/v1/compat_sessions/list.rs b/crates/handlers/src/admin/v1/compat_sessions/list.rs index debb2a304..b407854f6 100644 --- a/crates/handlers/src/admin/v1/compat_sessions/list.rs +++ b/crates/handlers/src/admin/v1/compat_sessions/list.rs @@ -4,11 +4,8 @@ // Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; -use axum::{ - Json, - extract::{Query, rejection::QueryRejection}, - response::IntoResponse, -}; +use axum::{Json, response::IntoResponse}; +use axum_extra::extract::{Query, QueryRejection}; use axum_macros::FromRequestParts; use hyper::StatusCode; use mas_axum_utils::record_error; @@ -21,7 +18,7 @@ use crate::{ admin::{ call_context::CallContext, model::{CompatSession, Resource}, - params::Pagination, + params::{IncludeCount, Pagination}, response::{ErrorResponse, PaginatedResponse}, }, impl_from_error_for_route, @@ -137,16 +134,22 @@ Use the `filter[status]` parameter to filter the sessions by their status and `p let sessions = CompatSession::samples(); let pagination = mas_storage::Pagination::first(sessions.len()); let page = Page { - edges: sessions.into(), + edges: sessions + .into_iter() + .map(|node| mas_storage::pagination::Edge { + cursor: node.id(), + node, + }) + .collect(), has_next_page: true, has_previous_page: false, }; t.description("Paginated response of compatibility sessions") - .example(PaginatedResponse::new( + .example(PaginatedResponse::for_page( page, pagination, - 42, + Some(42), CompatSession::PATH, )) }) @@ -159,10 +162,11 @@ Use the `filter[status]` parameter to filter the sessions by their status and `p #[tracing::instrument(name = "handler.admin.v1.compat_sessions.list", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, - Pagination(pagination): Pagination, + Pagination(pagination, include_count): Pagination, params: FilterParams, ) -> Result>, RouteError> { let base = format!("{path}{params}", path = CompatSession::PATH); + let base = include_count.add_to_base(&base); let filter = CompatSessionFilter::default(); // Load the user from the filter @@ -206,15 +210,31 @@ pub async fn handler( None => filter, }; - let page = repo.compat_session().list(filter, pagination).await?; - let count = repo.compat_session().count(filter).await?; + let response = match include_count { + IncludeCount::True => { + let page = repo + .compat_session() + .list(filter, pagination) + .await? + .map(CompatSession::from); + let count = repo.compat_session().count(filter).await?; + PaginatedResponse::for_page(page, pagination, Some(count), &base) + } + IncludeCount::False => { + let page = repo + .compat_session() + .list(filter, pagination) + .await? + .map(CompatSession::from); + PaginatedResponse::for_page(page, pagination, None, &base) + } + IncludeCount::Only => { + let count = repo.compat_session().count(filter).await?; + PaginatedResponse::for_count_only(count, &base) + } + }; - Ok(Json(PaginatedResponse::new( - page.map(CompatSession::from), - pagination, - count, - &base, - ))) + Ok(Json(response)) } #[cfg(test)] @@ -299,6 +319,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y" + }, + "meta": { + "page": { + "cursor": "01FSHNB530AAPR7PEV8KNBZD5Y" + } } }, { @@ -318,6 +343,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHNCZP0PPF7X0EVMJNECPZW" + }, + "meta": { + "page": { + "cursor": "01FSHNCZP0PPF7X0EVMJNECPZW" + } } } ], @@ -362,6 +392,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y" + }, + "meta": { + "page": { + "cursor": "01FSHNB530AAPR7PEV8KNBZD5Y" + } } } ], @@ -403,6 +438,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y" + }, + "meta": { + "page": { + "cursor": "01FSHNB530AAPR7PEV8KNBZD5Y" + } } } ], @@ -444,6 +484,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/compat-sessions/01FSHNCZP0PPF7X0EVMJNECPZW" + }, + "meta": { + "page": { + "cursor": "01FSHNCZP0PPF7X0EVMJNECPZW" + } } } ], @@ -454,5 +499,155 @@ mod tests { } } "#); + + // Test count=false + let request = Request::get("/api/admin/v1/compat-sessions?count=false") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "compat-session", + "id": "01FSHNB530AAPR7PEV8KNBZD5Y", + "attributes": { + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "device_id": "LoieH5Iecx", + "user_session_id": null, + "redirect_uri": null, + "created_at": "2022-01-16T14:41:00Z", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null, + "finished_at": null, + "human_name": null + }, + "links": { + "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y" + }, + "meta": { + "page": { + "cursor": "01FSHNB530AAPR7PEV8KNBZD5Y" + } + } + }, + { + "type": "compat-session", + "id": "01FSHNCZP0PPF7X0EVMJNECPZW", + "attributes": { + "user_id": "01FSHNB530AJ6AC5HQ9X6H4RP4", + "device_id": "ZXyvelQWW9", + "user_session_id": null, + "redirect_uri": null, + "created_at": "2022-01-16T14:42:00Z", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null, + "finished_at": "2022-01-16T14:43:00Z", + "human_name": null + }, + "links": { + "self": "/api/admin/v1/compat-sessions/01FSHNCZP0PPF7X0EVMJNECPZW" + }, + "meta": { + "page": { + "cursor": "01FSHNCZP0PPF7X0EVMJNECPZW" + } + } + } + ], + "links": { + "self": "/api/admin/v1/compat-sessions?count=false&page[first]=10", + "first": "/api/admin/v1/compat-sessions?count=false&page[first]=10", + "last": "/api/admin/v1/compat-sessions?count=false&page[last]=10" + } + } + "#); + + // Test count=only + let request = Request::get("/api/admin/v1/compat-sessions?count=only") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 2 + }, + "links": { + "self": "/api/admin/v1/compat-sessions?count=only" + } + } + "#); + + // Test count=false with filtering + let request = Request::get(format!( + "/api/admin/v1/compat-sessions?count=false&filter[user]={}", + alice.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "compat-session", + "id": "01FSHNB530AAPR7PEV8KNBZD5Y", + "attributes": { + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "device_id": "LoieH5Iecx", + "user_session_id": null, + "redirect_uri": null, + "created_at": "2022-01-16T14:41:00Z", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null, + "finished_at": null, + "human_name": null + }, + "links": { + "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y" + }, + "meta": { + "page": { + "cursor": "01FSHNB530AAPR7PEV8KNBZD5Y" + } + } + } + ], + "links": { + "self": "/api/admin/v1/compat-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10", + "first": "/api/admin/v1/compat-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10", + "last": "/api/admin/v1/compat-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[last]=10" + } + } + "#); + + // Test count=only with filtering + let request = + Request::get("/api/admin/v1/compat-sessions?count=only&filter[status]=active") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 1 + }, + "links": { + "self": "/api/admin/v1/compat-sessions?filter[status]=active&count=only" + } + } + "#); } } diff --git a/crates/handlers/src/admin/v1/compat_sessions/mod.rs b/crates/handlers/src/admin/v1/compat_sessions/mod.rs index 18ffe5af6..db7b17ff5 100644 --- a/crates/handlers/src/admin/v1/compat_sessions/mod.rs +++ b/crates/handlers/src/admin/v1/compat_sessions/mod.rs @@ -3,10 +3,12 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +mod finish; mod get; mod list; pub use self::{ + finish::{doc as finish_doc, handler as finish}, get::{doc as get_doc, handler as get}, list::{doc as list_doc, handler as list}, }; diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index 8a182bf2f..3e03b78ad 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -11,7 +11,11 @@ use aide::axum::{ routing::{get_with, post_with}, }; use axum::extract::{FromRef, FromRequestParts}; +<<<<<<< HEAD use mas_data_model::{BoxRng, SiteConfig}; +======= +use mas_data_model::{AppVersion, BoxRng, SiteConfig}; +>>>>>>> v1.6.0 use mas_matrix::HomeserverConnection; use mas_policy::PolicyFactory; @@ -20,6 +24,7 @@ use crate::passwords::PasswordManager; mod compat_sessions; mod oauth2_sessions; +mod personal_sessions; mod policy_data; mod site_config; mod upstream_oauth_links; @@ -28,6 +33,7 @@ mod user_emails; mod user_registration_tokens; mod user_sessions; mod users; +mod version; pub fn router() -> ApiRouter where @@ -35,6 +41,10 @@ where Arc: FromRef, PasswordManager: FromRef, SiteConfig: FromRef, +<<<<<<< HEAD +======= + AppVersion: FromRef, +>>>>>>> v1.6.0 Arc: FromRef, BoxRng: FromRequestParts, CallContext: FromRequestParts, @@ -45,6 +55,13 @@ where get_with(self::site_config::handler, self::site_config::doc), ) .api_route( +<<<<<<< HEAD +======= + "/version", + get_with(self::version::handler, self::version::doc), + ) + .api_route( +>>>>>>> v1.6.0 "/compat-sessions", get_with(self::compat_sessions::list, self::compat_sessions::list_doc), ) @@ -52,6 +69,13 @@ where "/compat-sessions/{id}", get_with(self::compat_sessions::get, self::compat_sessions::get_doc), ) + .api_route( + "/compat-sessions/{id}/finish", + post_with( + self::compat_sessions::finish, + self::compat_sessions::finish_doc, + ), + ) .api_route( "/oauth2-sessions", get_with(self::oauth2_sessions::list, self::oauth2_sessions::list_doc), @@ -60,6 +84,45 @@ where "/oauth2-sessions/{id}", get_with(self::oauth2_sessions::get, self::oauth2_sessions::get_doc), ) + .api_route( + "/oauth2-sessions/{id}/finish", + post_with( + self::oauth2_sessions::finish, + self::oauth2_sessions::finish_doc, + ), + ) + .api_route( + "/personal-sessions", + get_with( + self::personal_sessions::list, + self::personal_sessions::list_doc, + ) + .post_with( + self::personal_sessions::add, + self::personal_sessions::add_doc, + ), + ) + .api_route( + "/personal-sessions/{id}", + get_with( + self::personal_sessions::get, + self::personal_sessions::get_doc, + ), + ) + .api_route( + "/personal-sessions/{id}/revoke", + post_with( + self::personal_sessions::revoke, + self::personal_sessions::revoke_doc, + ), + ) + .api_route( + "/personal-sessions/{id}/regenerate", + post_with( + self::personal_sessions::regenerate, + self::personal_sessions::regenerate_doc, + ), + ) .api_route( "/policy-data", post_with(self::policy_data::set, self::policy_data::set_doc), @@ -130,6 +193,10 @@ where "/user-sessions/{id}", get_with(self::user_sessions::get, self::user_sessions::get_doc), ) + .api_route( + "/user-sessions/{id}/finish", + post_with(self::user_sessions::finish, self::user_sessions::finish_doc), + ) .api_route( "/user-registration-tokens", get_with( @@ -195,4 +262,14 @@ where self::upstream_oauth_providers::list_doc, ), ) +<<<<<<< HEAD +======= + .api_route( + "/upstream-oauth-providers/{id}", + get_with( + self::upstream_oauth_providers::get, + self::upstream_oauth_providers::get_doc, + ), + ) +>>>>>>> v1.6.0 } diff --git a/crates/handlers/src/admin/v1/oauth2_sessions/finish.rs b/crates/handlers/src/admin/v1/oauth2_sessions/finish.rs new file mode 100644 index 000000000..23edef30a --- /dev/null +++ b/crates/handlers/src/admin/v1/oauth2_sessions/finish.rs @@ -0,0 +1,234 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{NoApi, OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_data_model::BoxRng; +use mas_storage::queue::{QueueJobRepositoryExt as _, SyncDevicesJob}; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{OAuth2Session, Resource}, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("OAuth 2.0 session with ID {0} not found")] + NotFound(Ulid), + + #[error("OAuth 2.0 session with ID {0} is already finished")] + AlreadyFinished(Ulid), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound(_) => StatusCode::NOT_FOUND, + Self::AlreadyFinished(_) => StatusCode::BAD_REQUEST, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("finishOAuth2Session") + .summary("Finish an OAuth 2.0 session") + .description( + "Calling this endpoint will finish the OAuth 2.0 session, preventing any further use. If the session has a user associated with it, a job will be scheduled to sync the user's devices with the homeserver.", + ) + .tag("oauth2-session") + .response_with::<200, Json>, _>(|t| { + // Get the finished session sample + let [_, _, finished_session] = OAuth2Session::samples(); + let id = finished_session.id(); + let response = SingleResponse::new( + finished_session, + format!("/api/admin/v1/oauth2-sessions/{id}/finish"), + ); + t.description("OAuth 2.0 session was finished").example(response) + }) + .response_with::<400, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::AlreadyFinished(Ulid::nil())); + t.description("Session is already finished") + .example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("OAuth 2.0 session was not found") + .example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.oauth2_sessions.finish", skip_all)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + NoApi(mut rng): NoApi, + id: UlidPathParam, +) -> Result>, RouteError> { + let id = *id; + let session = repo + .oauth2_session() + .lookup(id) + .await? + .ok_or(RouteError::NotFound(id))?; + + // Check if the session is already finished + if session.finished_at().is_some() { + return Err(RouteError::AlreadyFinished(id)); + } + + // If the session has a user associated with it, schedule a job to sync devices + if let Some(user_id) = session.user_id { + tracing::info!(user.id = %user_id, "Scheduling device sync job for user"); + let job = SyncDevicesJob::new_for_id(user_id); + repo.queue_job().schedule_job(&mut rng, &clock, job).await?; + } + + // Finish the session + let session = repo.oauth2_session().finish(&clock, session).await?; + + repo.save().await?; + + Ok(Json(SingleResponse::new( + OAuth2Session::from(session), + format!("/api/admin/v1/oauth2-sessions/{id}/finish"), + ))) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode}; + use mas_data_model::{AccessToken, Clock as _}; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Get the session ID from the token we just created + let mut repo = state.repository().await.unwrap(); + let AccessToken { session_id, .. } = repo + .oauth2_access_token() + .find_by_token(&token) + .await + .unwrap() + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::post(format!("/api/admin/v1/oauth2-sessions/{session_id}/finish")) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // The finished_at timestamp should be the same as the current time + assert_eq!( + body["data"]["attributes"]["finished_at"], + serde_json::json!(state.clock.now()) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_already_finished_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + + // Create first admin token for the API call + let admin_token = state.token_with_scope("urn:mas:admin").await; + + // Create a second admin session that we'll finish + let second_admin_token = state.token_with_scope("urn:mas:admin").await; + + // Get the second session and finish it first + let mut repo = state.repository().await.unwrap(); + let AccessToken { session_id, .. } = repo + .oauth2_access_token() + .find_by_token(&second_admin_token) + .await + .unwrap() + .unwrap(); + + let session = repo + .oauth2_session() + .lookup(session_id) + .await + .unwrap() + .unwrap(); + + // Finish the session first + let session = repo + .oauth2_session() + .finish(&state.clock, session) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Move the clock forward + state.clock.advance(Duration::try_minutes(1).unwrap()); + + let request = Request::post(format!( + "/api/admin/v1/oauth2-sessions/{}/finish", + session.id + )) + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + format!( + "OAuth 2.0 session with ID {} is already finished", + session.id + ) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_unknown_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = + Request::post("/api/admin/v1/oauth2-sessions/01040G2081040G2081040G2081/finish") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + "OAuth 2.0 session with ID 01040G2081040G2081040G2081 not found" + ); + } +} diff --git a/crates/handlers/src/admin/v1/oauth2_sessions/list.rs b/crates/handlers/src/admin/v1/oauth2_sessions/list.rs index 52b597edc..37f6ed378 100644 --- a/crates/handlers/src/admin/v1/oauth2_sessions/list.rs +++ b/crates/handlers/src/admin/v1/oauth2_sessions/list.rs @@ -7,11 +7,8 @@ use std::str::FromStr; use aide::{OperationIo, transform::TransformOperation}; -use axum::{ - Json, - extract::{Query, rejection::QueryRejection}, - response::IntoResponse, -}; +use axum::{Json, response::IntoResponse}; +use axum_extra::extract::{Query, QueryRejection}; use axum_macros::FromRequestParts; use hyper::StatusCode; use mas_axum_utils::record_error; @@ -25,7 +22,7 @@ use crate::{ admin::{ call_context::CallContext, model::{OAuth2Session, Resource}, - params::Pagination, + params::{IncludeCount, Pagination}, response::{ErrorResponse, PaginatedResponse}, }, impl_from_error_for_route, @@ -192,16 +189,22 @@ Use the `filter[status]` parameter to filter the sessions by their status and `p let sessions = OAuth2Session::samples(); let pagination = mas_storage::Pagination::first(sessions.len()); let page = Page { - edges: sessions.into(), + edges: sessions + .into_iter() + .map(|node| mas_storage::pagination::Edge { + cursor: node.id(), + node, + }) + .collect(), has_next_page: true, has_previous_page: false, }; t.description("Paginated response of OAuth 2.0 sessions") - .example(PaginatedResponse::new( + .example(PaginatedResponse::for_page( page, pagination, - 42, + Some(42), OAuth2Session::PATH, )) }) @@ -218,10 +221,11 @@ Use the `filter[status]` parameter to filter the sessions by their status and `p #[tracing::instrument(name = "handler.admin.v1.oauth2_sessions.list", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, - Pagination(pagination): Pagination, + Pagination(pagination, include_count): Pagination, params: FilterParams, ) -> Result>, RouteError> { let base = format!("{path}{params}", path = OAuth2Session::PATH); + let base = include_count.add_to_base(&base); let filter = OAuth2SessionFilter::default(); // Load the user from the filter @@ -300,15 +304,31 @@ pub async fn handler( None => filter, }; - let page = repo.oauth2_session().list(filter, pagination).await?; - let count = repo.oauth2_session().count(filter).await?; + let response = match include_count { + IncludeCount::True => { + let page = repo + .oauth2_session() + .list(filter, pagination) + .await? + .map(OAuth2Session::from); + let count = repo.oauth2_session().count(filter).await?; + PaginatedResponse::for_page(page, pagination, Some(count), &base) + } + IncludeCount::False => { + let page = repo + .oauth2_session() + .list(filter, pagination) + .await? + .map(OAuth2Session::from); + PaginatedResponse::for_page(page, pagination, None, &base) + } + IncludeCount::Only => { + let count = repo.oauth2_session().count(filter).await?; + PaginatedResponse::for_count_only(count, &base) + } + }; - Ok(Json(PaginatedResponse::new( - page.map(OAuth2Session::from), - pagination, - count, - &base, - ))) + Ok(Json(response)) } #[cfg(test)] @@ -354,6 +374,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/oauth2-sessions/01FSHN9AG0MKGTBNZ16RDR3PVY" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MKGTBNZ16RDR3PVY" + } } } ], @@ -364,5 +389,66 @@ mod tests { } } "#); + + // Test count=false + let request = Request::get("/api/admin/v1/oauth2-sessions?count=false") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "oauth2-session", + "id": "01FSHN9AG0MKGTBNZ16RDR3PVY", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "finished_at": null, + "user_id": null, + "user_session_id": null, + "client_id": "01FSHN9AG0FAQ50MT1E9FFRPZR", + "scope": "urn:mas:admin", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null, + "human_name": null + }, + "links": { + "self": "/api/admin/v1/oauth2-sessions/01FSHN9AG0MKGTBNZ16RDR3PVY" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MKGTBNZ16RDR3PVY" + } + } + } + ], + "links": { + "self": "/api/admin/v1/oauth2-sessions?count=false&page[first]=10", + "first": "/api/admin/v1/oauth2-sessions?count=false&page[first]=10", + "last": "/api/admin/v1/oauth2-sessions?count=false&page[last]=10" + } + } + "#); + + // Test count=only + let request = Request::get("/api/admin/v1/oauth2-sessions?count=only") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 1 + }, + "links": { + "self": "/api/admin/v1/oauth2-sessions?count=only" + } + } + "#); } } diff --git a/crates/handlers/src/admin/v1/oauth2_sessions/mod.rs b/crates/handlers/src/admin/v1/oauth2_sessions/mod.rs index 9b6272cef..5ac2e049e 100644 --- a/crates/handlers/src/admin/v1/oauth2_sessions/mod.rs +++ b/crates/handlers/src/admin/v1/oauth2_sessions/mod.rs @@ -4,10 +4,12 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +mod finish; mod get; mod list; pub use self::{ + finish::{doc as finish_doc, handler as finish}, get::{doc as get_doc, handler as get}, list::{doc as list_doc, handler as list}, }; diff --git a/crates/handlers/src/admin/v1/personal_sessions/add.rs b/crates/handlers/src/admin/v1/personal_sessions/add.rs new file mode 100644 index 000000000..2cfe1fb88 --- /dev/null +++ b/crates/handlers/src/admin/v1/personal_sessions/add.rs @@ -0,0 +1,311 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use std::sync::Arc; + +use aide::{NoApi, OperationIo, transform::TransformOperation}; +use anyhow::Context; +use axum::{Json, extract::State, response::IntoResponse}; +use chrono::Duration; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_data_model::{BoxRng, Device, TokenType}; +use mas_matrix::HomeserverConnection; +use oauth2_types::scope::Scope; +use schemars::JsonSchema; +use serde::Deserialize; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{InconsistentPersonalSession, PersonalSession}, + response::{ErrorResponse, SingleResponse}, + v1::personal_sessions::personal_session_owner_from_caller, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("User not found")] + UserNotFound, + + #[error("User is not active")] + UserDeactivated, + + #[error("Invalid scope")] + InvalidScope, +} + +impl_from_error_for_route!(mas_storage::RepositoryError); +impl_from_error_for_route!(InconsistentPersonalSession); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::UserNotFound => StatusCode::NOT_FOUND, + Self::UserDeactivated => StatusCode::GONE, + Self::InvalidScope => StatusCode::BAD_REQUEST, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +/// # JSON payload for the `POST /api/admin/v1/personal-sessions` endpoint +#[derive(Deserialize, JsonSchema)] +#[serde(rename = "CreatePersonalSessionRequest")] +pub struct Request { + /// The user this session will act on behalf of + #[schemars(with = "crate::admin::schema::Ulid")] + actor_user_id: Ulid, + + /// Human-readable name for the session + human_name: String, + + /// `OAuth2` scopes for this session + scope: String, + + /// Token expiry time in seconds. + /// If not set, the token won't expire. + expires_in: Option, +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("createPersonalSession") + .summary("Create a new personal session with personal access token") + .tag("personal-session") + .response_with::<201, Json>, _>(|t| { + t.description("Personal session and personal access token were created") + }) + .response_with::<400, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::InvalidScope); + t.description("Invalid scope provided").example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::UserNotFound); + t.description("User was not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.personal_sessions.add", skip_all)] +pub async fn handler( + CallContext { + mut repo, + clock, + session, + .. + }: CallContext, + NoApi(mut rng): NoApi, + NoApi(State(homeserver)): NoApi>>, + Json(params): Json, +) -> Result<(StatusCode, Json>), RouteError> { + let owner = personal_session_owner_from_caller(&session); + + let actor_user = repo + .user() + .lookup(params.actor_user_id) + .await? + .ok_or(RouteError::UserNotFound)?; + + if !actor_user.is_valid_actor() { + return Err(RouteError::UserDeactivated); + } + + let scope: Scope = params.scope.parse().map_err(|_| RouteError::InvalidScope)?; + + // Create the personal session + let session = repo + .personal_session() + .add( + &mut rng, + &clock, + owner, + &actor_user, + params.human_name, + scope, + ) + .await?; + + // Create the initial token for the session + let access_token_string = TokenType::PersonalAccessToken.generate(&mut rng); + let access_token = repo + .personal_access_token() + .add( + &mut rng, + &clock, + &session, + &access_token_string, + params + .expires_in + .map(|exp_in| Duration::seconds(i64::from(exp_in))), + ) + .await?; + + // If the session has a device, we should add those to the homeserver now + if session.has_device() { + // Lock the user sync to make sure we don't get into a race condition + repo.user().acquire_lock_for_sync(&actor_user).await?; + + for scope in &*session.scope { + if let Some(device) = Device::from_scope_token(scope) { + // NOTE: We haven't relinquished the repo at this point, + // so we are holding a transaction across the homeserver + // operation. + // This is suboptimal, but simpler. + // Given this is an administrative endpoint, this is a tolerable + // compromise for now. + homeserver + .upsert_device(&actor_user.username, device.as_str(), None) + .await + .context("Failed to provision device") + .map_err(|e| RouteError::Internal(e.into()))?; + } + } + } + + repo.save().await?; + + Ok(( + StatusCode::CREATED, + Json(SingleResponse::new_canonical( + PersonalSession::try_from((session, Some(access_token)))? + .with_token(access_token_string), + )), + )) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use serde_json::Value; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_create_personal_session_with_token(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Create a user for testing + let mut repo = state.repository().await.unwrap(); + let mut rng = state.rng(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request_body = serde_json::json!({ + "actor_user_id": user.id, + "human_name": "Test Session", + "scope": "openid urn:mas:admin", + "expires_in": 3600 + }); + + let request = Request::post("/api/admin/v1/personal-sessions") + .bearer(&token) + .json(&request_body); + + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + + let body: Value = response.json(); + + assert_json_snapshot!(body, @r#" + { + "data": { + "type": "personal-session", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "revoked_at": null, + "owner_user_id": null, + "owner_client_id": "01FSHN9AG0FAQ50MT1E9FFRPZR", + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_name": "Test Session", + "scope": "openid urn:mas:admin", + "last_active_at": null, + "last_active_ip": null, + "expires_at": "2022-01-16T15:40:00Z", + "access_token": "mpt_FM44zJN5qePGMLvvMXC4Ds1A3lCWc6_bJ9Wj1" + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG07HNEZXNQM2KNBNF6" + } + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG07HNEZXNQM2KNBNF6" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_create_personal_session_invalid_user(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request_body = serde_json::json!({ + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "scope": "openid", + "human_name": "Test Session", + "expires_in": 3600 + }); + + let request = Request::post("/api/admin/v1/personal-sessions") + .bearer(&token) + .json(&request_body); + + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_create_personal_session_invalid_scope(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Create a user for testing + let mut repo = state.repository().await.unwrap(); + let mut rng = state.rng(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request_body = serde_json::json!({ + "actor_user_id": user.id, + "human_name": "Test Session", + "scope": "invalid\nscope", + "expires_in": 3600 + }); + + let request = Request::post("/api/admin/v1/personal-sessions") + .bearer(&token) + .json(&request_body); + + let response = state.request(request).await; + response.assert_status(StatusCode::BAD_REQUEST); + } +} diff --git a/crates/handlers/src/admin/v1/personal_sessions/get.rs b/crates/handlers/src/admin/v1/personal_sessions/get.rs new file mode 100644 index 000000000..c0c0378f8 --- /dev/null +++ b/crates/handlers/src/admin/v1/personal_sessions/get.rs @@ -0,0 +1,189 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; + +use crate::{ + admin::{ + call_context::CallContext, + model::{InconsistentPersonalSession, PersonalSession}, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Personal session not found")] + NotFound, +} + +impl_from_error_for_route!(mas_storage::RepositoryError); +impl_from_error_for_route!(InconsistentPersonalSession); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound => StatusCode::NOT_FOUND, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("getPersonalSession") + .summary("Get a personal session") + .tag("personal-session") + .response_with::<200, Json>, _>(|t| { + let [sample, ..] = PersonalSession::samples(); + let response = SingleResponse::new_canonical(sample); + t.description("Personal session details").example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound); + t.description("Personal session not found") + .example(response) + }) +} + +#[tracing::instrument( + name = "handler.admin.v1.personal_sessions.get", + skip_all, + fields(personal_session.id = %*id), +)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + id: UlidPathParam, +) -> Result>, RouteError> { + let session_id = *id; + + let session = repo + .personal_session() + .lookup(session_id) + .await? + .ok_or(RouteError::NotFound)?; + + let token = if session.is_revoked() { + None + } else { + repo.personal_access_token() + .find_active_for_session(&session) + .await? + }; + + Ok(Json(SingleResponse::new_canonical( + PersonalSession::try_from((session, token))?, + ))) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use mas_data_model::personal::session::PersonalSessionOwner; + use oauth2_types::scope::{OPENID, Scope}; + use sqlx::PgPool; + use ulid::Ulid; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_get(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Create a user and personal session for testing + let mut repo = state.repository().await.unwrap(); + let mut rng = state.rng(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + let personal_session = repo + .personal_session() + .add( + &mut rng, + &state.clock, + PersonalSessionOwner::from(&user), + &user, + "Test session".to_owned(), + Scope::from_iter([OPENID]), + ) + .await + .unwrap(); + repo.personal_access_token() + .add(&mut rng, &state.clock, &personal_session, "mpt_hiss", None) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request = Request::get(format!( + "/api/admin/v1/personal-sessions/{}", + personal_session.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_eq!(body["data"]["id"], personal_session.id.to_string()); + assert_json_snapshot!(body, @r#" + { + "data": { + "type": "personal-session", + "id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "revoked_at": null, + "owner_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_name": "Test session", + "scope": "openid", + "last_active_at": null, + "last_active_ip": null, + "expires_at": null + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_not_found(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let session_id = Ulid::nil(); + let request = Request::get(format!("/api/admin/v1/personal-sessions/{session_id}")) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + } +} diff --git a/crates/handlers/src/admin/v1/personal_sessions/list.rs b/crates/handlers/src/admin/v1/personal_sessions/list.rs new file mode 100644 index 000000000..c9d3d55d4 --- /dev/null +++ b/crates/handlers/src/admin/v1/personal_sessions/list.rs @@ -0,0 +1,585 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use std::str::FromStr as _; + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use axum_extra::extract::{Query, QueryRejection}; +use axum_macros::FromRequestParts; +use chrono::{DateTime, Utc}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_storage::personal::PersonalSessionFilter; +use oauth2_types::scope::{Scope, ScopeToken}; +use schemars::JsonSchema; +use serde::Deserialize; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{InconsistentPersonalSession, PersonalSession, Resource}, + params::{IncludeCount, Pagination}, + response::{ErrorResponse, PaginatedResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Deserialize, JsonSchema, Clone, Copy)] +#[serde(rename_all = "snake_case")] +enum PersonalSessionStatus { + Active, + Revoked, +} + +impl std::fmt::Display for PersonalSessionStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Active => write!(f, "active"), + Self::Revoked => write!(f, "revoked"), + } + } +} + +#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)] +#[serde(rename = "PersonalSessionFilter")] +#[aide(input_with = "Query")] +#[from_request(via(Query), rejection(RouteError))] +pub struct FilterParams { + /// Filter by owner user ID + #[serde(rename = "filter[owner_user]")] + #[schemars(with = "Option")] + owner_user: Option, + + /// Filter by owner `OAuth2` client ID + #[serde(rename = "filter[owner_client]")] + #[schemars(with = "Option")] + owner_client: Option, + + /// Filter by actor user ID + #[serde(rename = "filter[actor_user]")] + #[schemars(with = "Option")] + actor_user: Option, + + /// Retrieve the items with the given scope + #[serde(default, rename = "filter[scope]")] + scope: Vec, + + /// Filter by session status + #[serde(rename = "filter[status]")] + status: Option, + + /// Filter by access token expiry date + #[serde(rename = "filter[expires_before]")] + expires_before: Option>, + + /// Filter by access token expiry date + #[serde(rename = "filter[expires_after]")] + expires_after: Option>, + + /// Filter by whether the access token has an expiry time + #[serde(rename = "filter[expires]")] + expires: Option, +} + +impl std::fmt::Display for FilterParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut sep = '?'; + + if let Some(owner_user) = self.owner_user { + write!(f, "{sep}filter[owner_user]={owner_user}")?; + sep = '&'; + } + if let Some(owner_client) = self.owner_client { + write!(f, "{sep}filter[owner_client]={owner_client}")?; + sep = '&'; + } + if let Some(actor_user) = self.actor_user { + write!(f, "{sep}filter[actor_user]={actor_user}")?; + sep = '&'; + } + for scope in &self.scope { + write!(f, "{sep}filter[scope]={scope}")?; + sep = '&'; + } + if let Some(status) = self.status { + write!(f, "{sep}filter[status]={status}")?; + sep = '&'; + } + if let Some(expires_before) = self.expires_before { + write!( + f, + "{sep}filter[expires_before]={}", + expires_before.format("%Y-%m-%dT%H:%M:%SZ") + )?; + sep = '&'; + } + if let Some(expires_after) = self.expires_after { + write!( + f, + "{sep}filter[expires_after]={}", + expires_after.format("%Y-%m-%dT%H:%M:%SZ") + )?; + sep = '&'; + } + if let Some(expires) = self.expires { + write!(f, "{sep}filter[expires]={expires}")?; + sep = '&'; + } + + let _ = sep; + Ok(()) + } +} + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("User ID {0} not found")] + UserNotFound(Ulid), + + #[error("Client ID {0} not found")] + ClientNotFound(Ulid), + + #[error("Invalid filter parameters")] + InvalidFilter(#[from] QueryRejection), + + #[error("Invalid scope {0:?} in filter parameters")] + InvalidScope(String), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); +impl_from_error_for_route!(InconsistentPersonalSession); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::UserNotFound(_) | Self::ClientNotFound(_) => StatusCode::NOT_FOUND, + Self::InvalidScope(_) | Self::InvalidFilter(_) => StatusCode::BAD_REQUEST, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("listPersonalSessions") + .summary("List personal sessions") + .description("Retrieve a list of personal sessions. +Note that by default, all sessions, including revoked ones are returned, with the oldest first. +Use the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions.") + .tag("personal-session") + .response_with::<200, Json>, _>(|t| { + let sessions = PersonalSession::samples(); + let pagination = mas_storage::Pagination::first(sessions.len()); + let page = mas_storage::Page { + edges: sessions + .into_iter() + .map(|node| mas_storage::pagination::Edge { + cursor: node.id(), + node, + }) + .collect(), + has_next_page: true, + has_previous_page: false, + }; + + t.description("Paginated response of personal sessions") + .example(PaginatedResponse::for_page( + page, + pagination, + Some(3), + PersonalSession::PATH, + )) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil())); + t.description("User was not found").example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::ClientNotFound(Ulid::nil())); + t.description("Client was not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.personal_sessions.list", skip_all)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + Pagination(pagination, include_count): Pagination, + params: FilterParams, +) -> Result>, RouteError> { + let base = format!("{path}{params}", path = PersonalSession::PATH); + let base = include_count.add_to_base(&base); + + let filter = PersonalSessionFilter::new(); + + let owner_user = if let Some(owner_user_id) = params.owner_user { + let owner_user = repo + .user() + .lookup(owner_user_id) + .await? + .ok_or(RouteError::UserNotFound(owner_user_id))?; + Some(owner_user) + } else { + None + }; + + let filter = match &owner_user { + Some(user) => filter.for_owner_user(user), + None => filter, + }; + + let owner_client = if let Some(owner_client_id) = params.owner_client { + let owner_client = repo + .oauth2_client() + .lookup(owner_client_id) + .await? + .ok_or(RouteError::ClientNotFound(owner_client_id))?; + Some(owner_client) + } else { + None + }; + + let filter = match &owner_client { + Some(client) => filter.for_owner_oauth2_client(client), + None => filter, + }; + + let actor_user = if let Some(actor_user_id) = params.actor_user { + let user = repo + .user() + .lookup(actor_user_id) + .await? + .ok_or(RouteError::UserNotFound(actor_user_id))?; + Some(user) + } else { + None + }; + + let filter = match &actor_user { + Some(user) => filter.for_actor_user(user), + None => filter, + }; + + let scope: Scope = params + .scope + .into_iter() + .map(|s| ScopeToken::from_str(&s).map_err(|_| RouteError::InvalidScope(s))) + .collect::>()?; + + let filter = if scope.is_empty() { + filter + } else { + filter.with_scope(&scope) + }; + + let filter = match params.status { + Some(PersonalSessionStatus::Active) => filter.active_only(), + Some(PersonalSessionStatus::Revoked) => filter.finished_only(), + None => filter, + }; + + let filter = if let Some(expires_after) = params.expires_after { + filter.with_expires_after(expires_after) + } else { + filter + }; + + let filter = if let Some(expires_before) = params.expires_before { + filter.with_expires_before(expires_before) + } else { + filter + }; + + let filter = if let Some(expires) = params.expires { + filter.with_expires(expires) + } else { + filter + }; + + let response = match include_count { + IncludeCount::True => { + let page = repo.personal_session().list(filter, pagination).await?; + let count = repo.personal_session().count(filter).await?; + PaginatedResponse::for_page( + page.try_map(PersonalSession::try_from)?, + pagination, + Some(count), + &base, + ) + } + IncludeCount::False => { + let page = repo.personal_session().list(filter, pagination).await?; + PaginatedResponse::for_page( + page.try_map(PersonalSession::try_from)?, + pagination, + None, + &base, + ) + } + IncludeCount::Only => { + let count = repo.personal_session().count(filter).await?; + PaginatedResponse::for_count_only(count, &base) + } + }; + + Ok(Json(response)) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use chrono::Duration; + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use mas_data_model::personal::session::PersonalSessionOwner; + use oauth2_types::scope::{OPENID, Scope}; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_list(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + + // Create a user and personal session for testing + let mut repo = state.repository().await.unwrap(); + let mut rng = state.rng(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + let personal_session = repo + .personal_session() + .add( + &mut rng, + &state.clock, + PersonalSessionOwner::from(&user), + &user, + "Test session".to_owned(), + Scope::from_iter([OPENID]), + ) + .await + .unwrap(); + repo.personal_access_token() + .add( + &mut rng, + &state.clock, + &personal_session, + "mpt_hiss", + Some(Duration::days(42)), + ) + .await + .unwrap(); + + state.clock.advance(Duration::days(1)); + + let personal_session = repo + .personal_session() + .add( + &mut rng, + &state.clock, + PersonalSessionOwner::from(&user), + &user, + "Another test session".to_owned(), + Scope::from_iter([OPENID]), + ) + .await + .unwrap(); + repo.personal_access_token() + .add( + &mut rng, + &state.clock, + &personal_session, + "mpt_scratch", + Some(Duration::days(21)), + ) + .await + .unwrap(); + repo.personal_session() + .revoke(&state.clock, personal_session) + .await + .unwrap(); + + state.clock.advance(Duration::days(1)); + + let personal_session = repo + .personal_session() + .add( + &mut rng, + &state.clock, + PersonalSessionOwner::from(&user), + &user, + "Another test session".to_owned(), + Scope::from_iter([OPENID, "urn:mas:admin".parse().unwrap()]), + ) + .await + .unwrap(); + repo.personal_access_token() + .add( + &mut rng, + &state.clock, + &personal_session, + "mpt_meow", + Some(Duration::days(14)), + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let token = state.token_with_scope("urn:mas:admin").await; + let request = Request::get("/api/admin/v1/personal-sessions") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 3 + }, + "data": [ + { + "type": "personal-session", + "id": "01FSHN9AG0YQYAR04VCYTHJ8SK", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "revoked_at": null, + "owner_user_id": "01FSHN9AG09FE39KETP6F390F8", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG09FE39KETP6F390F8", + "human_name": "Test session", + "scope": "openid", + "last_active_at": null, + "last_active_ip": null, + "expires_at": "2022-02-27T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0YQYAR04VCYTHJ8SK" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0YQYAR04VCYTHJ8SK" + } + } + }, + { + "type": "personal-session", + "id": "01FSM7P1G0VBGAMK9D9QMGQ5MY", + "attributes": { + "created_at": "2022-01-17T14:40:00Z", + "revoked_at": "2022-01-17T14:40:00Z", + "owner_user_id": "01FSHN9AG09FE39KETP6F390F8", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG09FE39KETP6F390F8", + "human_name": "Another test session", + "scope": "openid", + "last_active_at": null, + "last_active_ip": null, + "expires_at": null + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSM7P1G0VBGAMK9D9QMGQ5MY" + }, + "meta": { + "page": { + "cursor": "01FSM7P1G0VBGAMK9D9QMGQ5MY" + } + } + }, + { + "type": "personal-session", + "id": "01FSPT2RG08Y11Y5BM4VZ4CN8K", + "attributes": { + "created_at": "2022-01-18T14:40:00Z", + "revoked_at": null, + "owner_user_id": "01FSHN9AG09FE39KETP6F390F8", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG09FE39KETP6F390F8", + "human_name": "Another test session", + "scope": "openid urn:mas:admin", + "last_active_at": null, + "last_active_ip": null, + "expires_at": "2022-02-01T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSPT2RG08Y11Y5BM4VZ4CN8K" + }, + "meta": { + "page": { + "cursor": "01FSPT2RG08Y11Y5BM4VZ4CN8K" + } + } + } + ], + "links": { + "self": "/api/admin/v1/personal-sessions?page[first]=10", + "first": "/api/admin/v1/personal-sessions?page[first]=10", + "last": "/api/admin/v1/personal-sessions?page[last]=10" + } + } + "#); + + // Map of filters to their expected set of returned ULIDs + let filters_and_expected: &[(&str, &[&str])] = &[ + ( + "filter[expires_before]=2022-02-15T00:00:00Z", + &["01FSPT2RG08Y11Y5BM4VZ4CN8K"], + ), + ( + "filter[expires_after]=2022-02-15T00:00:00Z", + &["01FSHN9AG0YQYAR04VCYTHJ8SK"], + ), + ( + "filter[status]=active", + &["01FSHN9AG0YQYAR04VCYTHJ8SK", "01FSPT2RG08Y11Y5BM4VZ4CN8K"], + ), + ("filter[status]=revoked", &["01FSM7P1G0VBGAMK9D9QMGQ5MY"]), + ( + "filter[expires]=true", + &["01FSHN9AG0YQYAR04VCYTHJ8SK", "01FSPT2RG08Y11Y5BM4VZ4CN8K"], + ), + ("filter[expires]=false", &["01FSM7P1G0VBGAMK9D9QMGQ5MY"]), + ( + "filter[scope]=urn:mas:admin", + &["01FSPT2RG08Y11Y5BM4VZ4CN8K"], + ), + ]; + + for (filter, expected_ids) in filters_and_expected { + let request = Request::get(format!("/api/admin/v1/personal-sessions?{filter}")) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + let found: BTreeSet<&str> = body["data"] + .as_array() + .unwrap() + .iter() + .map(|item| item["id"].as_str().unwrap()) + .collect(); + let expected: BTreeSet<&str> = expected_ids.iter().copied().collect(); + + assert_eq!( + found, expected, + "filter {filter} did not produce expected results" + ); + } + } +} diff --git a/crates/handlers/src/admin/v1/personal_sessions/mod.rs b/crates/handlers/src/admin/v1/personal_sessions/mod.rs new file mode 100644 index 000000000..37c591b09 --- /dev/null +++ b/crates/handlers/src/admin/v1/personal_sessions/mod.rs @@ -0,0 +1,39 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +mod add; +mod get; +mod list; +mod regenerate; +mod revoke; + +use mas_data_model::personal::session::PersonalSessionOwner; + +pub use self::{ + add::{doc as add_doc, handler as add}, + get::{doc as get_doc, handler as get}, + list::{doc as list_doc, handler as list}, + regenerate::{doc as regenerate_doc, handler as regenerate}, + revoke::{doc as revoke_doc, handler as revoke}, +}; +use crate::admin::call_context::CallerSession; + +/// Given the [`CallerSession`] of a caller of the Admin API, +/// return the [`PersonalSessionOwner`] that should own created personal +/// sessions. +fn personal_session_owner_from_caller(caller: &CallerSession) -> PersonalSessionOwner { + match caller { + CallerSession::OAuth2Session(session) => { + if let Some(user_id) = session.user_id { + PersonalSessionOwner::User(user_id) + } else { + PersonalSessionOwner::OAuth2Client(session.client_id) + } + } + CallerSession::PersonalSession(session) => { + PersonalSessionOwner::User(session.actor_user_id) + } + } +} diff --git a/crates/handlers/src/admin/v1/personal_sessions/regenerate.rs b/crates/handlers/src/admin/v1/personal_sessions/regenerate.rs new file mode 100644 index 000000000..e6c70679f --- /dev/null +++ b/crates/handlers/src/admin/v1/personal_sessions/regenerate.rs @@ -0,0 +1,246 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{NoApi, OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use chrono::Duration; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_data_model::{BoxRng, TokenType}; +use schemars::JsonSchema; +use serde::Deserialize; +use tracing::error; + +use crate::{ + admin::{ + call_context::CallContext, + model::{InconsistentPersonalSession, PersonalSession}, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + v1::personal_sessions::personal_session_owner_from_caller, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("User not found")] + UserNotFound, + + #[error("Session not found")] + SessionNotFound, + + #[error("Session not valid")] + SessionNotValid, + + #[error("Session does not belong to you")] + SessionNotYours, +} + +impl_from_error_for_route!(mas_storage::RepositoryError); +impl_from_error_for_route!(InconsistentPersonalSession); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::UserNotFound | Self::SessionNotFound => StatusCode::NOT_FOUND, + Self::SessionNotValid => StatusCode::UNPROCESSABLE_ENTITY, + Self::SessionNotYours => StatusCode::FORBIDDEN, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +/// # JSON payload for the `POST /api/admin/v1/personal-sessions/{id}/regenerate` endpoint +#[derive(Deserialize, JsonSchema)] +#[serde(rename = "RegeneratePersonalSessionRequest")] +pub struct Request { + /// Token expiry time in seconds. + /// If not set, the token won't expire. + expires_in: Option, +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("regeneratePersonalSession") + .summary("Regenerate a personal session by replacing its personal access token") + .tag("personal-session") + .response_with::<201, Json>, _>(|t| { + t.description( + "Personal session was regenerated and a personal access token was created", + ) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::UserNotFound); + t.description("User was not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.personal_sessions.add", skip_all)] +pub async fn handler( + CallContext { + mut repo, + clock, + session: caller_session, + .. + }: CallContext, + NoApi(mut rng): NoApi, + id: UlidPathParam, + Json(params): Json, +) -> Result<(StatusCode, Json>), RouteError> { + let session_id = *id; + + let session = repo + .personal_session() + .lookup(session_id) + .await? + .ok_or(RouteError::SessionNotFound)?; + + if !session.is_valid() { + // We don't revive revoked sessions through regeneration + return Err(RouteError::SessionNotValid); + } + + // If the owner is not the current caller, then currently we reject the + // regeneration. + let caller = personal_session_owner_from_caller(&caller_session); + if session.owner != caller { + return Err(RouteError::SessionNotYours); + } + + // Revoke the existing active token for the session. + let old_token_opt = repo + .personal_access_token() + .find_active_for_session(&session) + .await?; + let Some(old_token) = old_token_opt else { + // This shouldn't happen + error!("session is supposedly valid but had no access token"); + return Err(RouteError::SessionNotValid); + }; + + repo.personal_access_token() + .revoke(&clock, old_token) + .await?; + + // Create the regenerated token for the session + let access_token_string = TokenType::PersonalAccessToken.generate(&mut rng); + let access_token = repo + .personal_access_token() + .add( + &mut rng, + &clock, + &session, + &access_token_string, + params + .expires_in + .map(|exp_in| Duration::seconds(i64::from(exp_in))), + ) + .await?; + + repo.save().await?; + + Ok(( + StatusCode::CREATED, + Json(SingleResponse::new_canonical( + PersonalSession::try_from((session, Some(access_token)))? + .with_token(access_token_string), + )), + )) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use serde_json::{Value, json}; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_regenerate_personal_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Create a user for testing + let mut repo = state.repository().await.unwrap(); + let mut rng = state.rng(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request = Request::post("/api/admin/v1/personal-sessions") + .bearer(&token) + .json(json!({ + "actor_user_id": user.id, + "human_name": "SuperDuperAdminCLITool Token", + "scope": "openid urn:mas:admin", + "expires_in": 3600 + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let created: Value = response.json(); + + let session_id = created["data"]["id"].as_str().unwrap(); + + state.clock.advance(Duration::minutes(3)); + + let request = Request::post(format!( + "/api/admin/v1/personal-sessions/{session_id}/regenerate" + )) + .bearer(&token) + .json(json!({ + "expires_in": 86400 + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + + let body: Value = response.json(); + + assert_json_snapshot!(body, @r#" + { + "data": { + "type": "personal-session", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "revoked_at": null, + "owner_user_id": null, + "owner_client_id": "01FSHN9AG0FAQ50MT1E9FFRPZR", + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_name": "SuperDuperAdminCLITool Token", + "scope": "openid urn:mas:admin", + "last_active_at": null, + "last_active_ip": null, + "expires_at": "2022-01-17T14:43:00Z", + "access_token": "mpt_6cq7FqNSYoosbXl3bbpfh9yNy9NzuR_0vOV2O" + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG07HNEZXNQM2KNBNF6" + } + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG07HNEZXNQM2KNBNF6" + } + } + "#); + } +} diff --git a/crates/handlers/src/admin/v1/personal_sessions/revoke.rs b/crates/handlers/src/admin/v1/personal_sessions/revoke.rs new file mode 100644 index 000000000..10fd6650f --- /dev/null +++ b/crates/handlers/src/admin/v1/personal_sessions/revoke.rs @@ -0,0 +1,250 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{NoApi, OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_data_model::BoxRng; +use mas_storage::queue::{QueueJobRepositoryExt as _, SyncDevicesJob}; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{InconsistentPersonalSession, PersonalSession}, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Personal session with ID {0} not found")] + NotFound(Ulid), + + #[error("Personal session with ID {0} is already revoked")] + AlreadyRevoked(Ulid), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); +impl_from_error_for_route!(InconsistentPersonalSession); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound(_) => StatusCode::NOT_FOUND, + Self::AlreadyRevoked(_) => StatusCode::CONFLICT, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("revokePersonalSession") + .summary("Revoke a personal session") + .tag("personal-session") + .response_with::<200, Json>, _>(|t| { + let [sample, ..] = PersonalSession::samples(); + let response = SingleResponse::new_canonical(sample); + t.description("Personal session was revoked") + .example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("Personal session not found") + .example(response) + }) + .response_with::<409, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::AlreadyRevoked(Ulid::nil())); + t.description("Personal session already revoked") + .example(response) + }) +} + +#[tracing::instrument( + name = "handler.admin.v1.personal_sessions.revoke", + skip_all, + fields(personal_session.id = %*session_id), +)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + NoApi(mut rng): NoApi, + session_id: UlidPathParam, +) -> Result>, RouteError> { + let session_id = *session_id; + let session = repo + .personal_session() + .lookup(session_id) + .await? + .ok_or(RouteError::NotFound(session_id))?; + + if session.is_revoked() { + return Err(RouteError::AlreadyRevoked(session_id)); + } + + let session = repo.personal_session().revoke(&clock, session).await?; + + if session.has_device() { + // If the session has a device, then we are now + // deleting a device and should schedule a device sync to clean up. + repo.queue_job() + .schedule_job( + &mut rng, + &clock, + SyncDevicesJob::new_for_id(session.actor_user_id), + ) + .await?; + } + + repo.save().await?; + + Ok(Json(SingleResponse::new_canonical( + PersonalSession::try_from((session, None))?, + ))) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode}; + use mas_data_model::{Clock, personal::session::PersonalSessionOwner}; + use oauth2_types::scope::Scope; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_revoke_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Create a user and personal session for testing + let mut repo = state.repository().await.unwrap(); + let mut rng = state.rng(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + let personal_session = repo + .personal_session() + .add( + &mut rng, + &state.clock, + PersonalSessionOwner::from(&user), + &user, + "Test session".to_owned(), + Scope::from_iter([]), + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request = Request::post(format!( + "/api/admin/v1/personal-sessions/{}/revoke", + personal_session.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // The revoked_at timestamp should be the same as the current time + assert_eq!( + body["data"]["attributes"]["revoked_at"], + serde_json::json!(Clock::now(&state.clock)) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_revoke_already_revoked_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Create a user and personal session for testing + let mut repo = state.repository().await.unwrap(); + let mut rng = state.rng(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + let personal_session = repo + .personal_session() + .add( + &mut rng, + &state.clock, + PersonalSessionOwner::from(&user), + &user, + "Test session".to_owned(), + Scope::from_iter([]), + ) + .await + .unwrap(); + + // Revoke the session first + let session = repo + .personal_session() + .revoke(&state.clock, personal_session) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Move the clock forward + state.clock.advance(Duration::try_minutes(1).unwrap()); + + let request = Request::post(format!( + "/api/admin/v1/personal-sessions/{}/revoke", + session.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::CONFLICT); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + format!("Personal session with ID {} is already revoked", session.id) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_revoke_unknown_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = + Request::post("/api/admin/v1/personal-sessions/01040G2081040G2081040G2081/revoke") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + "Personal session with ID 01040G2081040G2081040G2081 not found" + ); + } +} diff --git a/crates/handlers/src/admin/v1/site_config.rs b/crates/handlers/src/admin/v1/site_config.rs index b9b05dac7..e8288e6df 100644 --- a/crates/handlers/src/admin/v1/site_config.rs +++ b/crates/handlers/src/admin/v1/site_config.rs @@ -22,6 +22,12 @@ pub struct SiteConfig { /// Whether password registration is enabled. pub password_registration_enabled: bool, +<<<<<<< HEAD +======= + /// Whether a valid email address is required for password registrations. + pub password_registration_email_required: bool, + +>>>>>>> v1.6.0 /// Whether registration tokens are required for password registrations. pub registration_token_required: bool, @@ -59,6 +65,10 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { server_name: "example.com".to_owned(), password_login_enabled: true, password_registration_enabled: true, +<<<<<<< HEAD +======= + password_registration_email_required: true, +>>>>>>> v1.6.0 registration_token_required: true, email_change_allowed: true, displayname_change_allowed: true, @@ -80,6 +90,10 @@ pub async fn handler( server_name: site_config.server_name, password_login_enabled: site_config.password_login_enabled, password_registration_enabled: site_config.password_registration_enabled, +<<<<<<< HEAD +======= + password_registration_email_required: site_config.password_registration_email_required, +>>>>>>> v1.6.0 registration_token_required: site_config.registration_token_required, email_change_allowed: site_config.email_change_allowed, displayname_change_allowed: site_config.displayname_change_allowed, diff --git a/crates/handlers/src/admin/v1/upstream_oauth_links/list.rs b/crates/handlers/src/admin/v1/upstream_oauth_links/list.rs index 59efe6541..c233a9977 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_links/list.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/list.rs @@ -4,11 +4,8 @@ // Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; -use axum::{ - Json, - extract::{Query, rejection::QueryRejection}, - response::IntoResponse, -}; +use axum::{Json, response::IntoResponse}; +use axum_extra::extract::{Query, QueryRejection}; use axum_macros::FromRequestParts; use hyper::StatusCode; use mas_axum_utils::record_error; @@ -21,7 +18,7 @@ use crate::{ admin::{ call_context::CallContext, model::{Resource, UpstreamOAuthLink}, - params::Pagination, + params::{IncludeCount, Pagination}, response::{ErrorResponse, PaginatedResponse}, }, impl_from_error_for_route, @@ -112,16 +109,22 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { let links = UpstreamOAuthLink::samples(); let pagination = mas_storage::Pagination::first(links.len()); let page = Page { - edges: links.into(), + edges: links + .into_iter() + .map(|node| mas_storage::pagination::Edge { + cursor: node.id(), + node, + }) + .collect(), has_next_page: true, has_previous_page: false, }; t.description("Paginated response of upstream OAuth 2.0 links") - .example(PaginatedResponse::new( + .example(PaginatedResponse::for_page( page, pagination, - 42, + Some(42), UpstreamOAuthLink::PATH, )) }) @@ -135,10 +138,11 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { #[tracing::instrument(name = "handler.admin.v1.upstream_oauth_links.list", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, - Pagination(pagination): Pagination, + Pagination(pagination, include_count): Pagination, params: FilterParams, ) -> Result>, RouteError> { let base = format!("{path}{params}", path = UpstreamOAuthLink::PATH); + let base = include_count.add_to_base(&base); let filter = UpstreamOAuthLinkFilter::default(); // Load the user from the filter @@ -183,15 +187,31 @@ pub async fn handler( filter }; - let page = repo.upstream_oauth_link().list(filter, pagination).await?; - let count = repo.upstream_oauth_link().count(filter).await?; + let response = match include_count { + IncludeCount::True => { + let page = repo + .upstream_oauth_link() + .list(filter, pagination) + .await? + .map(UpstreamOAuthLink::from); + let count = repo.upstream_oauth_link().count(filter).await?; + PaginatedResponse::for_page(page, pagination, Some(count), &base) + } + IncludeCount::False => { + let page = repo + .upstream_oauth_link() + .list(filter, pagination) + .await? + .map(UpstreamOAuthLink::from); + PaginatedResponse::for_page(page, pagination, None, &base) + } + IncludeCount::Only => { + let count = repo.upstream_oauth_link().count(filter).await?; + PaginatedResponse::for_count_only(count, &base) + } + }; - Ok(Json(PaginatedResponse::new( - page.map(UpstreamOAuthLink::from), - pagination, - count, - &base, - ))) + Ok(Json(response)) } #[cfg(test)] @@ -296,7 +316,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "meta": { "count": 3 @@ -314,6 +334,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0AQZQP8DX40GD59PW" + } } }, { @@ -328,6 +353,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0PJZ6DZNTAA1XKPT4" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0PJZ6DZNTAA1XKPT4" + } } }, { @@ -342,6 +372,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0QHEHKX2JNQ2A2D07" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0QHEHKX2JNQ2A2D07" + } } } ], @@ -351,7 +386,7 @@ mod tests { "last": "/api/admin/v1/upstream-oauth-links?page[last]=10" } } - "###); + "#); // Filter by user ID let request = Request::get(format!( @@ -364,7 +399,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "meta": { "count": 2 @@ -382,6 +417,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0AQZQP8DX40GD59PW" + } } }, { @@ -396,6 +436,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0QHEHKX2JNQ2A2D07" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0QHEHKX2JNQ2A2D07" + } } } ], @@ -405,7 +450,7 @@ mod tests { "last": "/api/admin/v1/upstream-oauth-links?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10" } } - "###); + "#); // Filter by provider let request = Request::get(format!( @@ -418,7 +463,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "meta": { "count": 2 @@ -436,6 +481,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0AQZQP8DX40GD59PW" + } } }, { @@ -450,6 +500,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0PJZ6DZNTAA1XKPT4" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0PJZ6DZNTAA1XKPT4" + } } } ], @@ -459,7 +514,7 @@ mod tests { "last": "/api/admin/v1/upstream-oauth-links?filter[provider]=01FSHN9AG09NMZYX8MFYH578R9&page[last]=10" } } - "###); + "#); // Filter by subject let request = Request::get(format!( @@ -472,7 +527,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "meta": { "count": 1 @@ -490,6 +545,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0AQZQP8DX40GD59PW" + } } } ], @@ -499,6 +559,181 @@ mod tests { "last": "/api/admin/v1/upstream-oauth-links?filter[subject]=subject1&page[last]=10" } } + "#); + + // Test count=false + let request = Request::get("/api/admin/v1/upstream-oauth-links?count=false") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "upstream-oauth-link", + "id": "01FSHN9AG0AQZQP8DX40GD59PW", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "provider_id": "01FSHN9AG09NMZYX8MFYH578R9", + "subject": "subject1", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_account_name": "alice@acme" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0AQZQP8DX40GD59PW" + } + } + }, + { + "type": "upstream-oauth-link", + "id": "01FSHN9AG0PJZ6DZNTAA1XKPT4", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "provider_id": "01FSHN9AG09NMZYX8MFYH578R9", + "subject": "subject3", + "user_id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "human_account_name": "bob@acme" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0PJZ6DZNTAA1XKPT4" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0PJZ6DZNTAA1XKPT4" + } + } + }, + { + "type": "upstream-oauth-link", + "id": "01FSHN9AG0QHEHKX2JNQ2A2D07", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "provider_id": "01FSHN9AG0KEPHYQQXW9XPTX6Z", + "subject": "subject2", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_account_name": "alice@example" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0QHEHKX2JNQ2A2D07" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0QHEHKX2JNQ2A2D07" + } + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-links?count=false&page[first]=10", + "first": "/api/admin/v1/upstream-oauth-links?count=false&page[first]=10", + "last": "/api/admin/v1/upstream-oauth-links?count=false&page[last]=10" + } + } + "#); + + // Test count=only + let request = Request::get("/api/admin/v1/upstream-oauth-links?count=only") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "meta": { + "count": 3 + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links?count=only" + } + } "###); + + // Test count=false with filtering + let request = Request::get(format!( + "/api/admin/v1/upstream-oauth-links?count=false&filter[user]={}", + alice.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "upstream-oauth-link", + "id": "01FSHN9AG0AQZQP8DX40GD59PW", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "provider_id": "01FSHN9AG09NMZYX8MFYH578R9", + "subject": "subject1", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_account_name": "alice@acme" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0AQZQP8DX40GD59PW" + } + } + }, + { + "type": "upstream-oauth-link", + "id": "01FSHN9AG0QHEHKX2JNQ2A2D07", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "provider_id": "01FSHN9AG0KEPHYQQXW9XPTX6Z", + "subject": "subject2", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_account_name": "alice@example" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0QHEHKX2JNQ2A2D07" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0QHEHKX2JNQ2A2D07" + } + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-links?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10", + "first": "/api/admin/v1/upstream-oauth-links?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10", + "last": "/api/admin/v1/upstream-oauth-links?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[last]=10" + } + } + "#); + + // Test count=only with filtering + let request = Request::get(format!( + "/api/admin/v1/upstream-oauth-links?count=only&filter[provider]={}", + provider1.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 2 + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links?filter[provider]=01FSHN9AG09NMZYX8MFYH578R9&count=only" + } + } + "#); } } diff --git a/crates/handlers/src/admin/v1/upstream_oauth_providers/get.rs b/crates/handlers/src/admin/v1/upstream_oauth_providers/get.rs new file mode 100644 index 000000000..3700e1a65 --- /dev/null +++ b/crates/handlers/src/admin/v1/upstream_oauth_providers/get.rs @@ -0,0 +1,196 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_storage::{RepositoryAccess, upstream_oauth2::UpstreamOAuthProviderRepository}; + +use crate::{ + admin::{ + call_context::CallContext, + model::UpstreamOAuthProvider, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Provider not found")] + NotFound, +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound => StatusCode::NOT_FOUND, + }; + + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("getUpstreamOAuthProvider") + .summary("Get upstream OAuth provider") + .tag("upstream-oauth-provider") + .response_with::<200, Json>, _>(|t| { + let [sample, ..] = UpstreamOAuthProvider::samples(); + t.description("The upstream OAuth provider") + .example(SingleResponse::new_canonical(sample)) + }) + .response_with::<404, Json, _>(|t| t.description("Provider not found")) +} + +#[tracing::instrument(name = "handler.admin.v1.upstream_oauth_providers.get", skip_all)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + id: UlidPathParam, +) -> Result>, RouteError> { + let provider = repo + .upstream_oauth_provider() + .lookup(*id) + .await? + .ok_or(RouteError::NotFound)?; + + Ok(Json(SingleResponse::new_canonical( + UpstreamOAuthProvider::from(provider), + ))) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use mas_data_model::{ + UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports, + UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderOnBackchannelLogout, + UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderTokenAuthMethod, + }; + use mas_iana::jose::JsonWebSignatureAlg; + use mas_storage::{ + RepositoryAccess, + upstream_oauth2::{UpstreamOAuthProviderParams, UpstreamOAuthProviderRepository}, + }; + use oauth2_types::scope::{OPENID, Scope}; + use sqlx::PgPool; + use ulid::Ulid; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + async fn create_test_provider(state: &mut TestState) -> UpstreamOAuthProvider { + let mut repo = state.repository().await.unwrap(); + + let params = UpstreamOAuthProviderParams { + issuer: Some("https://accounts.google.com".to_owned()), + human_name: Some("Google".to_owned()), + brand_name: Some("google".to_owned()), + discovery_mode: UpstreamOAuthProviderDiscoveryMode::Oidc, + pkce_mode: UpstreamOAuthProviderPkceMode::Auto, + jwks_uri_override: None, + authorization_endpoint_override: None, + token_endpoint_override: None, + userinfo_endpoint_override: None, + fetch_userinfo: true, + userinfo_signed_response_alg: None, + client_id: "google-client-id".to_owned(), + encrypted_client_secret: Some("encrypted-secret".to_owned()), + token_endpoint_signing_alg: None, + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::ClientSecretPost, + id_token_signed_response_alg: JsonWebSignatureAlg::Rs256, + response_mode: None, + scope: Scope::from_iter([OPENID]), + claims_imports: UpstreamOAuthProviderClaimsImports::default(), + additional_authorization_parameters: vec![], + forward_login_hint: false, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, + ui_order: 0, + }; + + let provider = repo + .upstream_oauth_provider() + .add(&mut state.rng(), &state.clock, params) + .await + .unwrap(); + + Box::new(repo).save().await.unwrap(); + + provider + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_get_provider(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + let provider = create_test_provider(&mut state).await; + + let request = Request::get(format!( + "/api/admin/v1/upstream-oauth-providers/{}", + provider.id + )) + .bearer(&admin_token) + .empty(); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json::(); + + assert_eq!(body["data"]["type"], "upstream-oauth-provider"); + assert_eq!(body["data"]["id"], provider.id.to_string()); + assert_eq!(body["data"]["attributes"]["human_name"], "Google"); + + insta::assert_json_snapshot!(body, @r###" + { + "data": { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "issuer": "https://accounts.google.com", + "human_name": "Google", + "brand_name": "google", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG0MZAA6S4AF7CTV32E" + } + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + "###); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_not_found(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + + let provider_id = Ulid::nil(); + let request = Request::get(format!( + "/api/admin/v1/upstream-oauth-providers/{provider_id}" + )) + .bearer(&admin_token) + .empty(); + + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + } +} diff --git a/crates/handlers/src/admin/v1/upstream_oauth_providers/list.rs b/crates/handlers/src/admin/v1/upstream_oauth_providers/list.rs index dc5f2cc9c..e703c601f 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_providers/list.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_providers/list.rs @@ -4,11 +4,16 @@ // Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; +<<<<<<< HEAD use axum::{ Json, extract::{Query, rejection::QueryRejection}, response::IntoResponse, }; +======= +use axum::{Json, response::IntoResponse}; +use axum_extra::extract::{Query, QueryRejection}; +>>>>>>> v1.6.0 use axum_macros::FromRequestParts; use hyper::StatusCode; use mas_axum_utils::record_error; @@ -20,7 +25,11 @@ use crate::{ admin::{ call_context::CallContext, model::{Resource, UpstreamOAuthProvider}, +<<<<<<< HEAD params::Pagination, +======= + params::{IncludeCount, Pagination}, +>>>>>>> v1.6.0 response::{ErrorResponse, PaginatedResponse}, }, impl_from_error_for_route, @@ -84,16 +93,33 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { let providers = UpstreamOAuthProvider::samples(); let pagination = mas_storage::Pagination::first(providers.len()); let page = Page { +<<<<<<< HEAD edges: providers.into(), +======= + edges: providers + .into_iter() + .map(|node| mas_storage::pagination::Edge { + cursor: node.id(), + node, + }) + .collect(), +>>>>>>> v1.6.0 has_next_page: true, has_previous_page: false, }; t.description("Paginated response of upstream OAuth 2.0 providers") +<<<<<<< HEAD .example(PaginatedResponse::new( page, pagination, 42, +======= + .example(PaginatedResponse::for_page( + page, + pagination, + Some(42), +>>>>>>> v1.6.0 UpstreamOAuthProvider::PATH, )) }) @@ -102,10 +128,18 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { #[tracing::instrument(name = "handler.admin.v1.upstream_oauth_providers.list", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, +<<<<<<< HEAD Pagination(pagination): Pagination, params: FilterParams, ) -> Result>, RouteError> { let base = format!("{path}{params}", path = UpstreamOAuthProvider::PATH); +======= + Pagination(pagination, include_count): Pagination, + params: FilterParams, +) -> Result>, RouteError> { + let base = format!("{path}{params}", path = UpstreamOAuthProvider::PATH); + let base = include_count.add_to_base(&base); +>>>>>>> v1.6.0 let filter = UpstreamOAuthProviderFilter::new(); let filter = match params.enabled { @@ -114,6 +148,7 @@ pub async fn handler( None => filter, }; +<<<<<<< HEAD let page = repo .upstream_oauth_provider() .list(filter, pagination) @@ -126,6 +161,33 @@ pub async fn handler( count, &base, ))) +======= + let response = match include_count { + IncludeCount::True => { + let page = repo + .upstream_oauth_provider() + .list(filter, pagination) + .await? + .map(UpstreamOAuthProvider::from); + let count = repo.upstream_oauth_provider().count(filter).await?; + PaginatedResponse::for_page(page, pagination, Some(count), &base) + } + IncludeCount::False => { + let page = repo + .upstream_oauth_provider() + .list(filter, pagination) + .await? + .map(UpstreamOAuthProvider::from); + PaginatedResponse::for_page(page, pagination, None, &base) + } + IncludeCount::Only => { + let count = repo.upstream_oauth_provider().count(filter).await?; + PaginatedResponse::for_count_only(count, &base) + } + }; + + Ok(Json(response)) +>>>>>>> v1.6.0 } #[cfg(test)] @@ -291,6 +353,14 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG07HNEZXNQM2KNBNF6" +<<<<<<< HEAD +======= + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } +>>>>>>> v1.6.0 } }, { @@ -305,6 +375,14 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG09AVTNSQFMSR34AJC" +<<<<<<< HEAD +======= + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } +>>>>>>> v1.6.0 } }, { @@ -319,6 +397,14 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG0MZAA6S4AF7CTV32E" +<<<<<<< HEAD +======= + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } +>>>>>>> v1.6.0 } } ], @@ -364,6 +450,14 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG09AVTNSQFMSR34AJC" +<<<<<<< HEAD +======= + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } +>>>>>>> v1.6.0 } }, { @@ -378,6 +472,14 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG0MZAA6S4AF7CTV32E" +<<<<<<< HEAD +======= + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } +>>>>>>> v1.6.0 } } ], @@ -423,6 +525,14 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG07HNEZXNQM2KNBNF6" +<<<<<<< HEAD +======= + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } +>>>>>>> v1.6.0 } } ], @@ -469,6 +579,14 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG07HNEZXNQM2KNBNF6" +<<<<<<< HEAD +======= + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } +>>>>>>> v1.6.0 } }, { @@ -483,6 +601,14 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG09AVTNSQFMSR34AJC" +<<<<<<< HEAD +======= + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } +>>>>>>> v1.6.0 } } ], @@ -525,6 +651,14 @@ mod tests { }, "links": { "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG0MZAA6S4AF7CTV32E" +<<<<<<< HEAD +======= + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } +>>>>>>> v1.6.0 } } ], @@ -551,4 +685,190 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::BAD_REQUEST); } +<<<<<<< HEAD +======= + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_count_parameter(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_providers(&mut state).await; + + // Test count=false + let request = Request::get("/api/admin/v1/upstream-oauth-providers?count=false") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json::(); + + insta::assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "issuer": "https://appleid.apple.com", + "human_name": "Apple ID", + "brand_name": "apple", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } + } + }, + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "issuer": "https://login.microsoftonline.com/common/v2.0", + "human_name": "Microsoft", + "brand_name": "microsoft", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } + } + }, + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "issuer": "https://accounts.google.com", + "human_name": "Google", + "brand_name": "google", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-providers?count=false&page[first]=10", + "first": "/api/admin/v1/upstream-oauth-providers?count=false&page[first]=10", + "last": "/api/admin/v1/upstream-oauth-providers?count=false&page[last]=10" + } + } + "#); + + // Test count=only + let request = Request::get("/api/admin/v1/upstream-oauth-providers?count=only") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json::(); + + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 3 + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers?count=only" + } + } + "#); + + // Test count=false with filtering + let request = + Request::get("/api/admin/v1/upstream-oauth-providers?count=false&filter[enabled]=true") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json::(); + + insta::assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "issuer": "https://login.microsoftonline.com/common/v2.0", + "human_name": "Microsoft", + "brand_name": "microsoft", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } + } + }, + { + "type": "upstream-oauth-provider", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "issuer": "https://accounts.google.com", + "human_name": "Google", + "brand_name": "google", + "created_at": "2022-01-16T14:40:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + } + ], + "links": { + "self": "/api/admin/v1/upstream-oauth-providers?filter[enabled]=true&count=false&page[first]=10", + "first": "/api/admin/v1/upstream-oauth-providers?filter[enabled]=true&count=false&page[first]=10", + "last": "/api/admin/v1/upstream-oauth-providers?filter[enabled]=true&count=false&page[last]=10" + } + } + "#); + + // Test count=only with filtering + let request = + Request::get("/api/admin/v1/upstream-oauth-providers?count=only&filter[enabled]=false") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json::(); + + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 1 + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers?filter[enabled]=false&count=only" + } + } + "#); + } +>>>>>>> v1.6.0 } diff --git a/crates/handlers/src/admin/v1/upstream_oauth_providers/mod.rs b/crates/handlers/src/admin/v1/upstream_oauth_providers/mod.rs index a04301246..dcd514444 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_providers/mod.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_providers/mod.rs @@ -3,6 +3,16 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +<<<<<<< HEAD mod list; pub use self::list::{doc as list_doc, handler as list}; +======= +mod get; +mod list; + +pub use self::{ + get::{doc as get_doc, handler as get}, + list::{doc as list_doc, handler as list}, +}; +>>>>>>> v1.6.0 diff --git a/crates/handlers/src/admin/v1/user_emails/list.rs b/crates/handlers/src/admin/v1/user_emails/list.rs index 92dfe12c2..453ef0e89 100644 --- a/crates/handlers/src/admin/v1/user_emails/list.rs +++ b/crates/handlers/src/admin/v1/user_emails/list.rs @@ -4,11 +4,8 @@ // Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; -use axum::{ - Json, - extract::{Query, rejection::QueryRejection}, - response::IntoResponse, -}; +use axum::{Json, response::IntoResponse}; +use axum_extra::extract::{Query, QueryRejection}; use axum_macros::FromRequestParts; use hyper::StatusCode; use mas_axum_utils::record_error; @@ -21,7 +18,7 @@ use crate::{ admin::{ call_context::CallContext, model::{Resource, UserEmail}, - params::Pagination, + params::{IncludeCount, Pagination}, response::{ErrorResponse, PaginatedResponse}, }, impl_from_error_for_route, @@ -99,16 +96,22 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { let emails = UserEmail::samples(); let pagination = mas_storage::Pagination::first(emails.len()); let page = Page { - edges: emails.into(), + edges: emails + .into_iter() + .map(|node| mas_storage::pagination::Edge { + cursor: node.id(), + node, + }) + .collect(), has_next_page: true, has_previous_page: false, }; t.description("Paginated response of user emails") - .example(PaginatedResponse::new( + .example(PaginatedResponse::for_page( page, pagination, - 42, + Some(42), UserEmail::PATH, )) }) @@ -121,10 +124,11 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { #[tracing::instrument(name = "handler.admin.v1.user_emails.list", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, - Pagination(pagination): Pagination, + Pagination(pagination, include_count): Pagination, params: FilterParams, ) -> Result>, RouteError> { let base = format!("{path}{params}", path = UserEmail::PATH); + let base = include_count.add_to_base(&base); let filter = UserEmailFilter::default(); // Load the user from the filter @@ -150,15 +154,31 @@ pub async fn handler( None => filter, }; - let page = repo.user_email().list(filter, pagination).await?; - let count = repo.user_email().count(filter).await?; + let response = match include_count { + IncludeCount::True => { + let page = repo + .user_email() + .list(filter, pagination) + .await? + .map(UserEmail::from); + let count = repo.user_email().count(filter).await?; + PaginatedResponse::for_page(page, pagination, Some(count), &base) + } + IncludeCount::False => { + let page = repo + .user_email() + .list(filter, pagination) + .await? + .map(UserEmail::from); + PaginatedResponse::for_page(page, pagination, None, &base) + } + IncludeCount::Only => { + let count = repo.user_email().count(filter).await?; + PaginatedResponse::for_count_only(count, &base) + } + }; - Ok(Json(PaginatedResponse::new( - page.map(UserEmail::from), - pagination, - count, - &base, - ))) + Ok(Json(response)) } #[cfg(test)] @@ -209,7 +229,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - insta::assert_json_snapshot!(body, @r###" + insta::assert_json_snapshot!(body, @r#" { "meta": { "count": 2 @@ -225,6 +245,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09NMZYX8MFYH578R9" + } } }, { @@ -237,6 +262,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-emails/01FSHN9AG0KEPHYQQXW9XPTX6Z" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0KEPHYQQXW9XPTX6Z" + } } } ], @@ -246,7 +276,7 @@ mod tests { "last": "/api/admin/v1/user-emails?page[last]=10" } } - "###); + "#); // Filter by user let request = Request::get(format!( @@ -258,7 +288,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - insta::assert_json_snapshot!(body, @r###" + insta::assert_json_snapshot!(body, @r#" { "meta": { "count": 1 @@ -274,6 +304,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09NMZYX8MFYH578R9" + } } } ], @@ -283,7 +318,7 @@ mod tests { "last": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10" } } - "###); + "#); // Filter by email let request = Request::get("/api/admin/v1/user-emails?filter[email]=alice@example.com") @@ -292,7 +327,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - insta::assert_json_snapshot!(body, @r###" + insta::assert_json_snapshot!(body, @r#" { "meta": { "count": 1 @@ -308,6 +343,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09NMZYX8MFYH578R9" + } } } ], @@ -317,6 +357,137 @@ mod tests { "last": "/api/admin/v1/user-emails?filter[email]=alice@example.com&page[last]=10" } } + "#); + + // Test count=false + let request = Request::get("/api/admin/v1/user-emails?count=false") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "user-email", + "id": "01FSHN9AG09NMZYX8MFYH578R9", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "email": "alice@example.com" + }, + "links": { + "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09NMZYX8MFYH578R9" + } + } + }, + { + "type": "user-email", + "id": "01FSHN9AG0KEPHYQQXW9XPTX6Z", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "user_id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "email": "bob@example.com" + }, + "links": { + "self": "/api/admin/v1/user-emails/01FSHN9AG0KEPHYQQXW9XPTX6Z" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0KEPHYQQXW9XPTX6Z" + } + } + } + ], + "links": { + "self": "/api/admin/v1/user-emails?count=false&page[first]=10", + "first": "/api/admin/v1/user-emails?count=false&page[first]=10", + "last": "/api/admin/v1/user-emails?count=false&page[last]=10" + } + } + "#); + + // Test count=only + let request = Request::get("/api/admin/v1/user-emails?count=only") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "meta": { + "count": 2 + }, + "links": { + "self": "/api/admin/v1/user-emails?count=only" + } + } "###); + + // Test count=false with filtering + let request = Request::get(format!( + "/api/admin/v1/user-emails?count=false&filter[user]={}", + alice.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "user-email", + "id": "01FSHN9AG09NMZYX8MFYH578R9", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "email": "alice@example.com" + }, + "links": { + "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09NMZYX8MFYH578R9" + } + } + } + ], + "links": { + "self": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10", + "first": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10", + "last": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[last]=10" + } + } + "#); + + // Test count=only with filtering + let request = Request::get(format!( + "/api/admin/v1/user-emails?count=only&filter[user]={}", + alice.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 1 + }, + "links": { + "self": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=only" + } + } + "#); } } diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/list.rs b/crates/handlers/src/admin/v1/user_registration_tokens/list.rs index 546491536..26e925401 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/list.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/list.rs @@ -5,11 +5,8 @@ // Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; -use axum::{ - Json, - extract::{Query, rejection::QueryRejection}, - response::IntoResponse, -}; +use axum::{Json, response::IntoResponse}; +use axum_extra::extract::{Query, QueryRejection}; use axum_macros::FromRequestParts; use hyper::StatusCode; use mas_axum_utils::record_error; @@ -21,7 +18,7 @@ use crate::{ admin::{ call_context::CallContext, model::{Resource, UserRegistrationToken}, - params::Pagination, + params::{IncludeCount, Pagination}, response::{ErrorResponse, PaginatedResponse}, }, impl_from_error_for_route, @@ -112,16 +109,22 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { let tokens = UserRegistrationToken::samples(); let pagination = mas_storage::Pagination::first(tokens.len()); let page = Page { - edges: tokens.into(), + edges: tokens + .into_iter() + .map(|node| mas_storage::pagination::Edge { + cursor: node.id(), + node, + }) + .collect(), has_next_page: true, has_previous_page: false, }; t.description("Paginated response of registration tokens") - .example(PaginatedResponse::new( + .example(PaginatedResponse::for_page( page, pagination, - 42, + Some(42), UserRegistrationToken::PATH, )) }) @@ -132,10 +135,11 @@ pub async fn handler( CallContext { mut repo, clock, .. }: CallContext, - Pagination(pagination): Pagination, + Pagination(pagination, include_count): Pagination, params: FilterParams, ) -> Result>, RouteError> { let base = format!("{path}{params}", path = UserRegistrationToken::PATH); + let base = include_count.add_to_base(&base); let now = clock.now(); let mut filter = UserRegistrationTokenFilter::new(now); @@ -155,18 +159,31 @@ pub async fn handler( filter = filter.with_valid(valid); } - let page = repo - .user_registration_token() - .list(filter, pagination) - .await?; - let count = repo.user_registration_token().count(filter).await?; - - Ok(Json(PaginatedResponse::new( - page.map(|token| UserRegistrationToken::new(token, now)), - pagination, - count, - &base, - ))) + let response = match include_count { + IncludeCount::True => { + let page = repo + .user_registration_token() + .list(filter, pagination) + .await? + .map(|token| UserRegistrationToken::new(token, now)); + let count = repo.user_registration_token().count(filter).await?; + PaginatedResponse::for_page(page, pagination, Some(count), &base) + } + IncludeCount::False => { + let page = repo + .user_registration_token() + .list(filter, pagination) + .await? + .map(|token| UserRegistrationToken::new(token, now)); + PaginatedResponse::for_page(page, pagination, None, &base) + } + IncludeCount::Only => { + let count = repo.user_registration_token().count(filter).await?; + PaginatedResponse::for_count_only(count, &base) + } + }; + + Ok(Json(response)) } #[cfg(test)] @@ -300,6 +317,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG064K8BYZXSY5G511Z" + } } }, { @@ -317,6 +339,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } } }, { @@ -334,6 +361,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } } }, { @@ -351,6 +383,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } } }, { @@ -368,6 +405,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0S3ZJD8CXQ7F11KXN" + } } } ], @@ -416,6 +458,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } } }, { @@ -433,6 +480,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0S3ZJD8CXQ7F11KXN" + } } } ], @@ -473,6 +525,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG064K8BYZXSY5G511Z" + } } }, { @@ -490,6 +547,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } } }, { @@ -507,6 +569,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } } } ], @@ -555,6 +622,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } } }, { @@ -572,6 +644,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0S3ZJD8CXQ7F11KXN" + } } } ], @@ -612,6 +689,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG064K8BYZXSY5G511Z" + } } }, { @@ -629,6 +711,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } } }, { @@ -646,6 +733,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } } } ], @@ -694,6 +786,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG064K8BYZXSY5G511Z" + } } } ], @@ -734,6 +831,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } } }, { @@ -751,6 +853,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } } }, { @@ -768,6 +875,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } } }, { @@ -785,6 +897,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0S3ZJD8CXQ7F11KXN" + } } } ], @@ -833,6 +950,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } } }, { @@ -850,6 +972,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } } } ], @@ -890,6 +1017,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG064K8BYZXSY5G511Z" + } } }, { @@ -907,6 +1039,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } } }, { @@ -924,6 +1061,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0S3ZJD8CXQ7F11KXN" + } } } ], @@ -974,6 +1116,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0S3ZJD8CXQ7F11KXN" + } } } ], @@ -1022,6 +1169,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG064K8BYZXSY5G511Z" + } } }, { @@ -1039,6 +1191,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } } } ], @@ -1080,6 +1237,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } } }, { @@ -1097,6 +1259,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } } } ], @@ -1138,6 +1305,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0S3ZJD8CXQ7F11KXN" + } } } ], @@ -1172,4 +1344,242 @@ mod tests { .contains("Invalid filter parameters") ); } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_count_parameter(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_tokens(&mut state).await; + + // Test count=false + let request = Request::get("/api/admin/v1/user-registration-tokens?count=false") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG064K8BYZXSY5G511Z", + "attributes": { + "token": "token_expired", + "valid": false, + "usage_limit": 5, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": "2022-01-15T14:40:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG064K8BYZXSY5G511Z" + } + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "token": "token_used", + "valid": true, + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "token": "token_revoked", + "valid": false, + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG09AVTNSQFMSR34AJC" + } + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "token_unused", + "valid": true, + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0S3ZJD8CXQ7F11KXN", + "attributes": { + "token": "token_used_revoked", + "valid": false, + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0S3ZJD8CXQ7F11KXN" + } + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?count=false&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?count=false&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?count=false&page[last]=10" + } + } + "#); + + // Test count=only + let request = Request::get("/api/admin/v1/user-registration-tokens?count=only") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 5 + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens?count=only" + } + } + "#); + + // Test count=false with filtering + let request = + Request::get("/api/admin/v1/user-registration-tokens?count=false&filter[valid]=true") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "token": "token_used", + "valid": true, + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG07HNEZXNQM2KNBNF6" + } + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "token_unused", + "valid": true, + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?filter[valid]=true&count=false&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?filter[valid]=true&count=false&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?filter[valid]=true&count=false&page[last]=10" + } + } + "#); + + // Test count=only with filtering + let request = + Request::get("/api/admin/v1/user-registration-tokens?count=only&filter[revoked]=true") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 2 + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens?filter[revoked]=true&count=only" + } + } + "#); + } } diff --git a/crates/handlers/src/admin/v1/user_sessions/finish.rs b/crates/handlers/src/admin/v1/user_sessions/finish.rs new file mode 100644 index 000000000..a50253f11 --- /dev/null +++ b/crates/handlers/src/admin/v1/user_sessions/finish.rs @@ -0,0 +1,216 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{Resource, UserSession}, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("User session with ID {0} not found")] + NotFound(Ulid), + + #[error("User session with ID {0} is already finished")] + AlreadyFinished(Ulid), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound(_) => StatusCode::NOT_FOUND, + Self::AlreadyFinished(_) => StatusCode::BAD_REQUEST, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("finishUserSession") + .summary("Finish a user session") + .description( + "Calling this endpoint will finish the user session, preventing any further use.", + ) + .tag("user-session") + .response_with::<200, Json>, _>(|t| { + // Get the finished session sample + let [_, _, finished_session] = UserSession::samples(); + let id = finished_session.id(); + let response = SingleResponse::new( + finished_session, + format!("/api/admin/v1/user-sessions/{id}/finish"), + ); + t.description("User session was finished").example(response) + }) + .response_with::<400, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::AlreadyFinished(Ulid::nil())); + t.description("Session is already finished") + .example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("User session was not found") + .example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.user_sessions.finish", skip_all)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + id: UlidPathParam, +) -> Result>, RouteError> { + let id = *id; + let session = repo + .browser_session() + .lookup(id) + .await? + .ok_or(RouteError::NotFound(id))?; + + // Check if the session is already finished + if session.finished_at.is_some() { + return Err(RouteError::AlreadyFinished(id)); + } + + // Finish the session + let session = repo.browser_session().finish(&clock, session).await?; + + repo.save().await?; + + Ok(Json(SingleResponse::new( + UserSession::from(session), + format!("/api/admin/v1/user-sessions/{id}/finish"), + ))) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode}; + use mas_data_model::Clock as _; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + // Provision a user and a user session + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let session = repo + .browser_session() + .add(&mut rng, &state.clock, &user, None) + .await + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::post(format!("/api/admin/v1/user-sessions/{}/finish", session.id)) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // The finished_at timestamp should be the same as the current time + assert_eq!( + body["data"]["attributes"]["finished_at"], + serde_json::json!(state.clock.now()) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_already_finished_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + // Provision a user and a user session + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let session = repo + .browser_session() + .add(&mut rng, &state.clock, &user, None) + .await + .unwrap(); + + // Finish the session first + let session = repo + .browser_session() + .finish(&state.clock, session) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Move the clock forward + state.clock.advance(Duration::try_minutes(1).unwrap()); + + let request = Request::post(format!("/api/admin/v1/user-sessions/{}/finish", session.id)) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + format!("User session with ID {} is already finished", session.id) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_unknown_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = + Request::post("/api/admin/v1/user-sessions/01040G2081040G2081040G2081/finish") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + "User session with ID 01040G2081040G2081040G2081 not found" + ); + } +} diff --git a/crates/handlers/src/admin/v1/user_sessions/list.rs b/crates/handlers/src/admin/v1/user_sessions/list.rs index 28a52edf2..ad8a05982 100644 --- a/crates/handlers/src/admin/v1/user_sessions/list.rs +++ b/crates/handlers/src/admin/v1/user_sessions/list.rs @@ -4,11 +4,8 @@ // Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; -use axum::{ - Json, - extract::{Query, rejection::QueryRejection}, - response::IntoResponse, -}; +use axum::{Json, response::IntoResponse}; +use axum_extra::extract::{Query, QueryRejection}; use axum_macros::FromRequestParts; use hyper::StatusCode; use mas_axum_utils::record_error; @@ -21,7 +18,7 @@ use crate::{ admin::{ call_context::CallContext, model::{Resource, UserSession}, - params::Pagination, + params::{IncludeCount, Pagination}, response::{ErrorResponse, PaginatedResponse}, }, impl_from_error_for_route, @@ -123,16 +120,22 @@ Use the `filter[status]` parameter to filter the sessions by their status and `p let sessions = UserSession::samples(); let pagination = mas_storage::Pagination::first(sessions.len()); let page = Page { - edges: sessions.into(), + edges: sessions + .into_iter() + .map(|node| mas_storage::pagination::Edge { + cursor: node.id(), + node, + }) + .collect(), has_next_page: true, has_previous_page: false, }; t.description("Paginated response of user sessions") - .example(PaginatedResponse::new( + .example(PaginatedResponse::for_page( page, pagination, - 42, + Some(42), UserSession::PATH, )) }) @@ -145,10 +148,11 @@ Use the `filter[status]` parameter to filter the sessions by their status and `p #[tracing::instrument(name = "handler.admin.v1.user_sessions.list", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, - Pagination(pagination): Pagination, + Pagination(pagination, include_count): Pagination, params: FilterParams, ) -> Result>, RouteError> { let base = format!("{path}{params}", path = UserSession::PATH); + let base = include_count.add_to_base(&base); let filter = BrowserSessionFilter::default(); // Load the user from the filter @@ -175,15 +179,31 @@ pub async fn handler( None => filter, }; - let page = repo.browser_session().list(filter, pagination).await?; - let count = repo.browser_session().count(filter).await?; + let response = match include_count { + IncludeCount::True => { + let page = repo + .browser_session() + .list(filter, pagination) + .await? + .map(UserSession::from); + let count = repo.browser_session().count(filter).await?; + PaginatedResponse::for_page(page, pagination, Some(count), &base) + } + IncludeCount::False => { + let page = repo + .browser_session() + .list(filter, pagination) + .await? + .map(UserSession::from); + PaginatedResponse::for_page(page, pagination, None, &base) + } + IncludeCount::Only => { + let count = repo.browser_session().count(filter).await?; + PaginatedResponse::for_count_only(count, &base) + } + }; - Ok(Json(PaginatedResponse::new( - page.map(UserSession::from), - pagination, - count, - &base, - ))) + Ok(Json(response)) } #[cfg(test)] @@ -241,7 +261,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "meta": { "count": 2 @@ -260,6 +280,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-sessions/01FSHNB5309NMZYX8MFYH578R9" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } } }, { @@ -275,6 +300,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-sessions/01FSHNB530KEPHYQQXW9XPTX6Z" + }, + "meta": { + "page": { + "cursor": "01FSHNB530AJ6AC5HQ9X6H4RP4" + } } } ], @@ -284,7 +314,7 @@ mod tests { "last": "/api/admin/v1/user-sessions?page[last]=10" } } - "###); + "#); // Filter by user let request = Request::get(format!( @@ -296,7 +326,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "meta": { "count": 1 @@ -315,6 +345,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-sessions/01FSHNB5309NMZYX8MFYH578R9" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } } } ], @@ -324,7 +359,7 @@ mod tests { "last": "/api/admin/v1/user-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10" } } - "###); + "#); // Filter by status (active) let request = Request::get("/api/admin/v1/user-sessions?filter[status]=active") @@ -333,7 +368,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "meta": { "count": 1 @@ -352,6 +387,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-sessions/01FSHNB5309NMZYX8MFYH578R9" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } } } ], @@ -361,7 +401,7 @@ mod tests { "last": "/api/admin/v1/user-sessions?filter[status]=active&page[last]=10" } } - "###); + "#); // Filter by status (finished) let request = Request::get("/api/admin/v1/user-sessions?filter[status]=finished") @@ -370,7 +410,7 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - assert_json_snapshot!(body, @r###" + assert_json_snapshot!(body, @r#" { "meta": { "count": 1 @@ -389,6 +429,11 @@ mod tests { }, "links": { "self": "/api/admin/v1/user-sessions/01FSHNB530KEPHYQQXW9XPTX6Z" + }, + "meta": { + "page": { + "cursor": "01FSHNB530AJ6AC5HQ9X6H4RP4" + } } } ], @@ -398,6 +443,143 @@ mod tests { "last": "/api/admin/v1/user-sessions?filter[status]=finished&page[last]=10" } } + "#); + + // Test count=false + let request = Request::get("/api/admin/v1/user-sessions?count=false") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "user-session", + "id": "01FSHNB5309NMZYX8MFYH578R9", + "attributes": { + "created_at": "2022-01-16T14:41:00Z", + "finished_at": null, + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null + }, + "links": { + "self": "/api/admin/v1/user-sessions/01FSHNB5309NMZYX8MFYH578R9" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + }, + { + "type": "user-session", + "id": "01FSHNB530KEPHYQQXW9XPTX6Z", + "attributes": { + "created_at": "2022-01-16T14:41:00Z", + "finished_at": "2022-01-16T14:42:00Z", + "user_id": "01FSHNB530AJ6AC5HQ9X6H4RP4", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null + }, + "links": { + "self": "/api/admin/v1/user-sessions/01FSHNB530KEPHYQQXW9XPTX6Z" + }, + "meta": { + "page": { + "cursor": "01FSHNB530AJ6AC5HQ9X6H4RP4" + } + } + } + ], + "links": { + "self": "/api/admin/v1/user-sessions?count=false&page[first]=10", + "first": "/api/admin/v1/user-sessions?count=false&page[first]=10", + "last": "/api/admin/v1/user-sessions?count=false&page[last]=10" + } + } + "#); + + // Test count=only + let request = Request::get("/api/admin/v1/user-sessions?count=only") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "meta": { + "count": 2 + }, + "links": { + "self": "/api/admin/v1/user-sessions?count=only" + } + } "###); + + // Test count=false with filtering + let request = Request::get(format!( + "/api/admin/v1/user-sessions?count=false&filter[user]={}", + alice.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "user-session", + "id": "01FSHNB5309NMZYX8MFYH578R9", + "attributes": { + "created_at": "2022-01-16T14:41:00Z", + "finished_at": null, + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null + }, + "links": { + "self": "/api/admin/v1/user-sessions/01FSHNB5309NMZYX8MFYH578R9" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + } + ], + "links": { + "self": "/api/admin/v1/user-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10", + "first": "/api/admin/v1/user-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10", + "last": "/api/admin/v1/user-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[last]=10" + } + } + "#); + + // Test count=only with filtering + let request = Request::get("/api/admin/v1/user-sessions?count=only&filter[status]=active") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 1 + }, + "links": { + "self": "/api/admin/v1/user-sessions?filter[status]=active&count=only" + } + } + "#); } } diff --git a/crates/handlers/src/admin/v1/user_sessions/mod.rs b/crates/handlers/src/admin/v1/user_sessions/mod.rs index 18ffe5af6..db7b17ff5 100644 --- a/crates/handlers/src/admin/v1/user_sessions/mod.rs +++ b/crates/handlers/src/admin/v1/user_sessions/mod.rs @@ -3,10 +3,12 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +mod finish; mod get; mod list; pub use self::{ + finish::{doc as finish_doc, handler as finish}, get::{doc as get_doc, handler as get}, list::{doc as list_doc, handler as list}, }; diff --git a/crates/handlers/src/admin/v1/users/list.rs b/crates/handlers/src/admin/v1/users/list.rs index da70e5807..65375402e 100644 --- a/crates/handlers/src/admin/v1/users/list.rs +++ b/crates/handlers/src/admin/v1/users/list.rs @@ -5,11 +5,8 @@ // Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; -use axum::{ - Json, - extract::{Query, rejection::QueryRejection}, - response::IntoResponse, -}; +use axum::{Json, response::IntoResponse}; +use axum_extra::extract::{Query, QueryRejection}; use axum_macros::FromRequestParts; use hyper::StatusCode; use mas_axum_utils::record_error; @@ -21,7 +18,7 @@ use crate::{ admin::{ call_context::CallContext, model::{Resource, User}, - params::Pagination, + params::{IncludeCount, Pagination}, response::{ErrorResponse, PaginatedResponse}, }, impl_from_error_for_route, @@ -137,23 +134,35 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { let users = User::samples(); let pagination = mas_storage::Pagination::first(users.len()); let page = Page { - edges: users.into(), + edges: users + .into_iter() + .map(|node| mas_storage::pagination::Edge { + cursor: node.id(), + node, + }) + .collect(), has_next_page: true, has_previous_page: false, }; t.description("Paginated response of users") - .example(PaginatedResponse::new(page, pagination, 42, User::PATH)) + .example(PaginatedResponse::for_page( + page, + pagination, + Some(42), + User::PATH, + )) }) } #[tracing::instrument(name = "handler.admin.v1.users.list", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, - Pagination(pagination): Pagination, + Pagination(pagination, include_count): Pagination, params: FilterParams, ) -> Result>, RouteError> { let base = format!("{path}{params}", path = User::PATH); + let base = include_count.add_to_base(&base); let filter = UserFilter::default(); let filter = match params.admin { @@ -180,13 +189,243 @@ pub async fn handler( None => filter, }; - let page = repo.user().list(filter, pagination).await?; - let count = repo.user().count(filter).await?; + let response = match include_count { + IncludeCount::True => { + let page = repo.user().list(filter, pagination).await?; + let count = repo.user().count(filter).await?; + PaginatedResponse::for_page(page.map(User::from), pagination, Some(count), &base) + } + IncludeCount::False => { + let page = repo.user().list(filter, pagination).await?; + PaginatedResponse::for_page(page.map(User::from), pagination, None, &base) + } + IncludeCount::Only => { + let count = repo.user().count(filter).await?; + PaginatedResponse::for_count_only(count, &base) + } + }; + + Ok(Json(response)) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_list_users(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + // Provision two users + let mut repo = state.repository().await.unwrap(); + repo.user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + repo.user() + .add(&mut rng, &state.clock, "bob".to_owned()) + .await + .unwrap(); + repo.save().await.unwrap(); - Ok(Json(PaginatedResponse::new( - page.map(User::from), - pagination, - count, - &base, - ))) + // Test default behavior (count=true) + let request = Request::get("/api/admin/v1/users").bearer(&token).empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 2 + }, + "data": [ + { + "type": "user", + "id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "attributes": { + "username": "bob", + "created_at": "2022-01-16T14:40:00Z", + "locked_at": null, + "deactivated_at": null, + "admin": false, + "legacy_guest": false + }, + "links": { + "self": "/api/admin/v1/users/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + } + }, + { + "type": "user", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "username": "alice", + "created_at": "2022-01-16T14:40:00Z", + "locked_at": null, + "deactivated_at": null, + "admin": false, + "legacy_guest": false + }, + "links": { + "self": "/api/admin/v1/users/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + } + ], + "links": { + "self": "/api/admin/v1/users?page[first]=10", + "first": "/api/admin/v1/users?page[first]=10", + "last": "/api/admin/v1/users?page[last]=10" + } + } + "#); + + // Test count=false + let request = Request::get("/api/admin/v1/users?count=false") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "user", + "id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "attributes": { + "username": "bob", + "created_at": "2022-01-16T14:40:00Z", + "locked_at": null, + "deactivated_at": null, + "admin": false, + "legacy_guest": false + }, + "links": { + "self": "/api/admin/v1/users/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + } + }, + { + "type": "user", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "username": "alice", + "created_at": "2022-01-16T14:40:00Z", + "locked_at": null, + "deactivated_at": null, + "admin": false, + "legacy_guest": false + }, + "links": { + "self": "/api/admin/v1/users/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + } + ], + "links": { + "self": "/api/admin/v1/users?count=false&page[first]=10", + "first": "/api/admin/v1/users?count=false&page[first]=10", + "last": "/api/admin/v1/users?count=false&page[last]=10" + } + } + "#); + + // Test count=only + let request = Request::get("/api/admin/v1/users?count=only") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "meta": { + "count": 2 + }, + "links": { + "self": "/api/admin/v1/users?count=only" + } + } + "###); + + // Test count=false with filtering + let request = Request::get("/api/admin/v1/users?count=false&filter[search]=alice") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "data": [ + { + "type": "user", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "username": "alice", + "created_at": "2022-01-16T14:40:00Z", + "locked_at": null, + "deactivated_at": null, + "admin": false, + "legacy_guest": false + }, + "links": { + "self": "/api/admin/v1/users/01FSHN9AG0MZAA6S4AF7CTV32E" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + } + ], + "links": { + "self": "/api/admin/v1/users?filter[search]=alice&count=false&page[first]=10", + "first": "/api/admin/v1/users?filter[search]=alice&count=false&page[first]=10", + "last": "/api/admin/v1/users?filter[search]=alice&count=false&page[last]=10" + } + } + "#); + + // Test count=only with filtering + let request = Request::get("/api/admin/v1/users?count=only&filter[search]=alice") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 1 + }, + "links": { + "self": "/api/admin/v1/users?filter[search]=alice&count=only" + } + } + "#); + } } diff --git a/crates/handlers/src/admin/v1/version.rs b/crates/handlers/src/admin/v1/version.rs new file mode 100644 index 000000000..2fe53940b --- /dev/null +++ b/crates/handlers/src/admin/v1/version.rs @@ -0,0 +1,62 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::transform::TransformOperation; +use axum::{Json, extract::State}; +use mas_data_model::AppVersion; +use schemars::JsonSchema; +use serde::Serialize; + +use crate::admin::call_context::CallContext; + +#[derive(Serialize, JsonSchema)] +pub struct Version { + /// The semver version of the app + pub version: &'static str, +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("version") + .tag("server") + .summary("Get the version currently running") + .response_with::<200, Json, _>(|t| t.example(Version { version: "v1.0.0" })) +} + +#[tracing::instrument(name = "handler.admin.v1.version", skip_all)] +pub async fn handler( + _: CallContext, + State(AppVersion(version)): State, +) -> Json { + Json(Version { version }) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_add_user(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = Request::get("/api/admin/v1/version").bearer(&token).empty(); + + let response = state.request(request).await; + + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r#" + { + "version": "v0.0.0-test" + } + "#); + } +} diff --git a/crates/handlers/src/bin/api-schema.rs b/crates/handlers/src/bin/api-schema.rs index 6eed219da..694b48dd2 100644 --- a/crates/handlers/src/bin/api-schema.rs +++ b/crates/handlers/src/bin/api-schema.rs @@ -60,6 +60,10 @@ impl_from_ref!(mas_keystore::Keystore); impl_from_ref!(mas_handlers::passwords::PasswordManager); impl_from_ref!(Arc); impl_from_ref!(mas_data_model::SiteConfig); +<<<<<<< HEAD +======= +impl_from_ref!(mas_data_model::AppVersion); +>>>>>>> v1.6.0 fn main() -> Result<(), Box> { let (mut api, _) = mas_handlers::admin_api_router::(); diff --git a/crates/handlers/src/compat/login_sso_complete.rs b/crates/handlers/src/compat/login_sso_complete.rs index 93b9f9dee..a4fbb24fb 100644 --- a/crates/handlers/src/compat/login_sso_complete.rs +++ b/crates/handlers/src/compat/login_sso_complete.rs @@ -8,9 +8,10 @@ use std::collections::HashMap; use anyhow::Context; use axum::{ - extract::{Form, Path, Query, State}, + extract::{Form, Path, State}, response::{Html, IntoResponse, Redirect, Response}, }; +use axum_extra::extract::Query; use chrono::Duration; use mas_axum_utils::{ InternalError, diff --git a/crates/handlers/src/compat/login_sso_redirect.rs b/crates/handlers/src/compat/login_sso_redirect.rs index 09af59b45..f085bb82f 100644 --- a/crates/handlers/src/compat/login_sso_redirect.rs +++ b/crates/handlers/src/compat/login_sso_redirect.rs @@ -4,10 +4,8 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. -use axum::{ - extract::{Query, State}, - response::IntoResponse, -}; +use axum::{extract::State, response::IntoResponse}; +use axum_extra::extract::Query; use hyper::StatusCode; use mas_axum_utils::{GenericError, InternalError}; use mas_data_model::{BoxClock, BoxRng}; diff --git a/crates/handlers/src/graphql/model/browser_sessions.rs b/crates/handlers/src/graphql/model/browser_sessions.rs index 925288067..08ba25830 100644 --- a/crates/handlers/src/graphql/model/browser_sessions.rs +++ b/crates/handlers/src/graphql/model/browser_sessions.rs @@ -172,7 +172,7 @@ impl BrowserSession { connection .edges - .extend(page.edges.into_iter().map(|s| match s { + .extend(page.edges.into_iter().map(|edge| match edge.node { mas_storage::app_session::AppSession::Compat(session) => Edge::new( OpaqueCursor(NodeCursor(NodeType::CompatSession, session.id)), AppSession::CompatSession(Box::new(CompatSession::new(*session))), diff --git a/crates/handlers/src/graphql/model/users.rs b/crates/handlers/src/graphql/model/users.rs index 11522c6b4..7e615df7d 100644 --- a/crates/handlers/src/graphql/model/users.rs +++ b/crates/handlers/src/graphql/model/users.rs @@ -125,10 +125,10 @@ impl User { page.has_next_page, PreloadedTotalCount(count), ); - connection.edges.extend(page.edges.into_iter().map(|u| { + connection.edges.extend(page.edges.into_iter().map(|edge| { Edge::new( - OpaqueCursor(NodeCursor(NodeType::CompatSsoLogin, u.id)), - CompatSsoLogin(u), + OpaqueCursor(NodeCursor(NodeType::CompatSsoLogin, edge.cursor)), + CompatSsoLogin(edge.node), ) })); @@ -219,14 +219,13 @@ impl User { page.has_next_page, PreloadedTotalCount(count), ); - connection - .edges - .extend(page.edges.into_iter().map(|(session, sso_login)| { - Edge::new( - OpaqueCursor(NodeCursor(NodeType::CompatSession, session.id)), - CompatSession::new(session).with_loaded_sso_login(sso_login), - ) - })); + connection.edges.extend(page.edges.into_iter().map(|edge| { + let (session, sso_login) = edge.node; + Edge::new( + OpaqueCursor(NodeCursor(NodeType::CompatSession, session.id)), + CompatSession::new(session).with_loaded_sso_login(sso_login), + ) + })); Ok::<_, async_graphql::Error>(connection) }, @@ -305,10 +304,10 @@ impl User { page.has_next_page, PreloadedTotalCount(count), ); - connection.edges.extend(page.edges.into_iter().map(|u| { + connection.edges.extend(page.edges.into_iter().map(|edge| { Edge::new( - OpaqueCursor(NodeCursor(NodeType::BrowserSession, u.id)), - BrowserSession(u), + OpaqueCursor(NodeCursor(NodeType::BrowserSession, edge.cursor)), + BrowserSession(edge.node), ) })); @@ -373,10 +372,10 @@ impl User { page.has_next_page, PreloadedTotalCount(count), ); - connection.edges.extend(page.edges.into_iter().map(|u| { + connection.edges.extend(page.edges.into_iter().map(|edge| { Edge::new( - OpaqueCursor(NodeCursor(NodeType::UserEmail, u.id)), - UserEmail(u), + OpaqueCursor(NodeCursor(NodeType::UserEmail, edge.cursor)), + UserEmail(edge.node), ) })); @@ -480,10 +479,10 @@ impl User { PreloadedTotalCount(count), ); - connection.edges.extend(page.edges.into_iter().map(|s| { + connection.edges.extend(page.edges.into_iter().map(|edge| { Edge::new( - OpaqueCursor(NodeCursor(NodeType::OAuth2Session, s.id)), - OAuth2Session(s), + OpaqueCursor(NodeCursor(NodeType::OAuth2Session, edge.cursor)), + OAuth2Session(edge.node), ) })); @@ -547,10 +546,10 @@ impl User { page.has_next_page, PreloadedTotalCount(count), ); - connection.edges.extend(page.edges.into_iter().map(|s| { + connection.edges.extend(page.edges.into_iter().map(|edge| { Edge::new( - OpaqueCursor(NodeCursor(NodeType::UpstreamOAuth2Link, s.id)), - UpstreamOAuth2Link::new(s), + OpaqueCursor(NodeCursor(NodeType::UpstreamOAuth2Link, edge.cursor)), + UpstreamOAuth2Link::new(edge.node), ) })); @@ -689,13 +688,13 @@ impl User { connection .edges - .extend(page.edges.into_iter().map(|s| match s { + .extend(page.edges.into_iter().map(|edge| match edge.node { mas_storage::app_session::AppSession::Compat(session) => Edge::new( - OpaqueCursor(NodeCursor(NodeType::CompatSession, session.id)), + OpaqueCursor(NodeCursor(NodeType::CompatSession, edge.cursor)), AppSession::CompatSession(Box::new(CompatSession::new(*session))), ), mas_storage::app_session::AppSession::OAuth2(session) => Edge::new( - OpaqueCursor(NodeCursor(NodeType::OAuth2Session, session.id)), + OpaqueCursor(NodeCursor(NodeType::OAuth2Session, edge.cursor)), AppSession::OAuth2Session(Box::new(OAuth2Session(*session))), ), })); diff --git a/crates/handlers/src/graphql/mutations/mod.rs b/crates/handlers/src/graphql/mutations/mod.rs index af6caab62..a84bf9210 100644 --- a/crates/handlers/src/graphql/mutations/mod.rs +++ b/crates/handlers/src/graphql/mutations/mod.rs @@ -84,7 +84,7 @@ async fn verify_password_if_needed( password, user_password.hashed_password, ) - .await; + .await?; - Ok(res.is_ok()) + Ok(res.is_success()) } diff --git a/crates/handlers/src/graphql/mutations/user.rs b/crates/handlers/src/graphql/mutations/user.rs index f9f5696e7..355c7d0ac 100644 --- a/crates/handlers/src/graphql/mutations/user.rs +++ b/crates/handlers/src/graphql/mutations/user.rs @@ -737,13 +737,14 @@ impl UserMutations { )); }; - if let Err(_err) = password_manager + if !password_manager .verify( active_password.version, Zeroizing::new(current_password_attempt), active_password.hashed_password, ) - .await + .await? + .is_success() { return Ok(SetPasswordPayload { status: SetPasswordStatus::WrongPassword, diff --git a/crates/handlers/src/graphql/query/session.rs b/crates/handlers/src/graphql/query/session.rs index 921009ee9..82ca55fd9 100644 --- a/crates/handlers/src/graphql/query/session.rs +++ b/crates/handlers/src/graphql/query/session.rs @@ -68,7 +68,8 @@ impl SessionQuery { ); } - if let Some((compat_session, sso_login)) = compat_sessions.edges.into_iter().next() { + if let Some(edge) = compat_sessions.edges.into_iter().next() { + let (compat_session, sso_login) = edge.node; repo.cancel().await?; return Ok(Some(Session::CompatSession(Box::new( @@ -92,10 +93,10 @@ impl SessionQuery { ); } - if let Some(session) = sessions.edges.into_iter().next() { + if let Some(edge) = sessions.edges.into_iter().next() { repo.cancel().await?; return Ok(Some(Session::OAuth2Session(Box::new(OAuth2Session( - session, + edge.node, ))))); } repo.cancel().await?; diff --git a/crates/handlers/src/graphql/query/upstream_oauth.rs b/crates/handlers/src/graphql/query/upstream_oauth.rs index f52c21f82..f0b4ceee6 100644 --- a/crates/handlers/src/graphql/query/upstream_oauth.rs +++ b/crates/handlers/src/graphql/query/upstream_oauth.rs @@ -130,10 +130,10 @@ impl UpstreamOAuthQuery { page.has_next_page, PreloadedTotalCount(count), ); - connection.edges.extend(page.edges.into_iter().map(|p| { + connection.edges.extend(page.edges.into_iter().map(|edge| { Edge::new( - OpaqueCursor(NodeCursor(NodeType::UpstreamOAuth2Provider, p.id)), - UpstreamOAuth2Provider::new(p), + OpaqueCursor(NodeCursor(NodeType::UpstreamOAuth2Provider, edge.cursor)), + UpstreamOAuth2Provider::new(edge.node), ) })); diff --git a/crates/handlers/src/graphql/query/user.rs b/crates/handlers/src/graphql/query/user.rs index 364319e57..bb55ef67b 100644 --- a/crates/handlers/src/graphql/query/user.rs +++ b/crates/handlers/src/graphql/query/user.rs @@ -143,11 +143,12 @@ impl UserQuery { page.has_next_page, PreloadedTotalCount(count), ); - connection.edges.extend( - page.edges.into_iter().map(|p| { - Edge::new(OpaqueCursor(NodeCursor(NodeType::User, p.id)), User(p)) - }), - ); + connection.edges.extend(page.edges.into_iter().map(|edge| { + Edge::new( + OpaqueCursor(NodeCursor(NodeType::User, edge.cursor)), + User(edge.node), + ) + })); Ok::<_, async_graphql::Error>(connection) }, diff --git a/crates/handlers/src/graphql/tests.rs b/crates/handlers/src/graphql/tests.rs index 328b6f152..888d477d0 100644 --- a/crates/handlers/src/graphql/tests.rs +++ b/crates/handlers/src/graphql/tests.rs @@ -6,6 +6,7 @@ use axum::http::Request; use hyper::StatusCode; +use mas_axum_utils::SessionInfoExt; use mas_data_model::{AccessToken, Client, TokenType, User}; use mas_matrix::{HomeserverConnection, ProvisionRequest}; use mas_router::SimpleRoute; @@ -19,11 +20,9 @@ use oauth2_types::{ scope::{OPENID, Scope, ScopeToken}, }; use sqlx::PgPool; +use zeroize::Zeroizing; -use crate::{ - test_utils, - test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}, -}; +use crate::test_utils::{self, CookieHelper, RequestBuilderExt, ResponseExt, TestState, setup}; async fn create_test_client(state: &TestState) -> Client { let mut repo = state.repository().await.unwrap(); @@ -781,3 +780,301 @@ async fn test_add_user(pool: PgPool) { }) ); } + +/// Test the setPassword mutation where the current password provided is +/// wrong. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_set_password_rejected_wrong_password(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let mut rng = state.rng(); + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let password = Zeroizing::new("current.password.123".to_owned()); + let (version, hashed_password) = state + .password_manager + .hash(&mut rng, password) + .await + .unwrap(); + + repo.user_password() + .add( + &mut rng, + &state.clock, + &user, + version, + hashed_password, + None, + ) + .await + .unwrap(); + let browser_session = repo + .browser_session() + .add(&mut rng, &state.clock, &user, None) + .await + .unwrap(); + repo.save().await.unwrap(); + + let cookie_jar = state.cookie_jar(); + let cookie_jar = cookie_jar.set_session(&browser_session); + + let user_id = user.id; + + let request = Request::post("/graphql").json(serde_json::json!({ + "query": format!(r#" + mutation {{ + setPassword(input: {{ + userId: "user:{user_id}", + currentPassword: "wrong.password.123", + newPassword: "new.password.123" + }}) {{ + status + }} + }} + "#), + })); + + let cookies = CookieHelper::new(); + cookies.import(cookie_jar); + let request = cookies.with_cookies(request); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data["setPassword"]["status"].as_str(), + Some("WRONG_PASSWORD"), + "{:?}", + response.data + ); +} + +/// Test the startEmailAuthentication mutation where the current password +/// provided is invalid. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_start_email_authentication_rejected_wrong_password(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let mut rng = state.rng(); + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let password = Zeroizing::new("current.password.123".to_owned()); + let (version, hashed_password) = state + .password_manager + .hash(&mut rng, password) + .await + .unwrap(); + + repo.user_password() + .add( + &mut rng, + &state.clock, + &user, + version, + hashed_password, + None, + ) + .await + .unwrap(); + let browser_session = repo + .browser_session() + .add(&mut rng, &state.clock, &user, None) + .await + .unwrap(); + repo.save().await.unwrap(); + + let cookie_jar = state.cookie_jar(); + let cookie_jar = cookie_jar.set_session(&browser_session); + + let request = Request::post("/graphql").json(serde_json::json!({ + "query": r#" + mutation { + startEmailAuthentication(input: { + email: "alice@example.org", + password: "wrong.password.123" + }) { + status + } + } + "#, + })); + + let cookies = CookieHelper::new(); + cookies.import(cookie_jar); + let request = cookies.with_cookies(request); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data["startEmailAuthentication"]["status"].as_str(), + Some("INCORRECT_PASSWORD"), + "{:?}", + response.data + ); +} + +/// Test the removeEmail mutation where the current password +/// provided is invalid. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_remove_email_rejected_wrong_password(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let mut rng = state.rng(); + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let password = Zeroizing::new("current.password.123".to_owned()); + let (version, hashed_password) = state + .password_manager + .hash(&mut rng, password) + .await + .unwrap(); + + repo.user_password() + .add( + &mut rng, + &state.clock, + &user, + version, + hashed_password, + None, + ) + .await + .unwrap(); + let user_email_id = repo + .user_email() + .add( + &mut rng, + &state.clock, + &user, + "alice@example.org".to_owned(), + ) + .await + .unwrap() + .id; + let browser_session = repo + .browser_session() + .add(&mut rng, &state.clock, &user, None) + .await + .unwrap(); + repo.save().await.unwrap(); + + let cookie_jar = state.cookie_jar(); + let cookie_jar = cookie_jar.set_session(&browser_session); + + let request = Request::post("/graphql").json(serde_json::json!({ + "query": format!(r#" + mutation {{ + removeEmail(input: {{ + userEmailId: "user_email:{user_email_id}", + password: "wrong.password.123" + }}) {{ + status + }} + }} + "#), + })); + + let cookies = CookieHelper::new(); + cookies.import(cookie_jar); + let request = cookies.with_cookies(request); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data["removeEmail"]["status"].as_str(), + Some("INCORRECT_PASSWORD"), + "{:?}", + response.data + ); +} + +/// Test the deactivateUser mutation where the current password +/// provided is invalid. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_deactivate_user_rejected_wrong_password(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let mut rng = state.rng(); + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let password = Zeroizing::new("current.password.123".to_owned()); + let (version, hashed_password) = state + .password_manager + .hash(&mut rng, password) + .await + .unwrap(); + + repo.user_password() + .add( + &mut rng, + &state.clock, + &user, + version, + hashed_password, + None, + ) + .await + .unwrap(); + let browser_session = repo + .browser_session() + .add(&mut rng, &state.clock, &user, None) + .await + .unwrap(); + repo.save().await.unwrap(); + + let cookie_jar = state.cookie_jar(); + let cookie_jar = cookie_jar.set_session(&browser_session); + + let request = Request::post("/graphql").json(serde_json::json!({ + "query": r#" + mutation { + deactivateUser(input: { + hsErase: true, + password: "wrong.password.123" + }) { + status + } + } + "#, + })); + + let cookies = CookieHelper::new(); + cookies.import(cookie_jar); + let request = cookies.with_cookies(request); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data["deactivateUser"]["status"].as_str(), + Some("INCORRECT_PASSWORD"), + "{:?}", + response.data + ); +} diff --git a/crates/handlers/src/oauth2/device/link.rs b/crates/handlers/src/oauth2/device/link.rs index da2fb2700..84d0c5077 100644 --- a/crates/handlers/src/oauth2/device/link.rs +++ b/crates/handlers/src/oauth2/device/link.rs @@ -5,9 +5,10 @@ // Please see LICENSE files in the repository root for full details. use axum::{ - extract::{Query, State}, + extract::State, response::{Html, IntoResponse}, }; +use axum_extra::extract::Query; use mas_axum_utils::{InternalError, cookies::CookieJar}; use mas_data_model::BoxClock; use mas_router::UrlBuilder; diff --git a/crates/handlers/src/oauth2/introspection.rs b/crates/handlers/src/oauth2/introspection.rs index 6bb61bf72..17f508921 100644 --- a/crates/handlers/src/oauth2/introspection.rs +++ b/crates/handlers/src/oauth2/introspection.rs @@ -15,7 +15,9 @@ use mas_axum_utils::{ client_authorization::{ClientAuthorization, CredentialsVerificationError}, record_error, }; -use mas_data_model::{BoxClock, Clock, Device, TokenFormatError, TokenType}; +use mas_data_model::{ + BoxClock, Clock, Device, TokenFormatError, TokenType, personal::session::PersonalSessionOwner, +}; use mas_iana::oauth::{OAuthClientAuthenticationMethod, OAuthTokenTypeHint}; use mas_keystore::Encrypter; use mas_matrix::HomeserverConnection; @@ -93,6 +95,14 @@ pub enum RouteError { #[error("unknown compat session {0}")] CantLoadCompatSession(Ulid), + /// The personal access token session is not valid. + #[error("invalid personal access token session {0}")] + InvalidPersonalSession(Ulid), + + /// The personal access token session could not be found in the database. + #[error("unknown personal access token session {0}")] + CantLoadPersonalSession(Ulid), + /// The Device ID in the compat session can't be encoded as a scope #[error("device ID contains characters that are not allowed in a scope")] CantEncodeDeviceID(#[from] mas_data_model::ToScopeTokenError), @@ -103,6 +113,9 @@ pub enum RouteError { #[error("unknown user {0}")] CantLoadUser(Ulid), + #[error("unknown OAuth2 client {0}")] + CantLoadOAuth2Client(Ulid), + #[error("bad request")] BadRequest, @@ -131,7 +144,9 @@ impl IntoResponse for RouteError { e @ (Self::Internal(_) | Self::CantLoadCompatSession(_) | Self::CantLoadOAuthSession(_) + | Self::CantLoadPersonalSession(_) | Self::CantLoadUser(_) + | Self::CantLoadOAuth2Client(_) | Self::FailedToVerifyToken(_)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json( @@ -167,6 +182,7 @@ impl IntoResponse for RouteError { | Self::InvalidUser(_) | Self::InvalidCompatSession(_) | Self::InvalidOAuthSession(_) + | Self::InvalidPersonalSession(_) | Self::InvalidTokenFormat(_) | Self::CantEncodeDeviceID(_) => { INTROSPECTION_COUNTER.add(1, &[KeyValue::new(ACTIVE.clone(), false)]); @@ -625,6 +641,97 @@ pub(crate) async fn post( device_id: session.device.map(Device::into), } } + + TokenType::PersonalAccessToken => { + let access_token = repo + .personal_access_token() + .find_by_token(token) + .await? + .ok_or(RouteError::UnknownToken(TokenType::AccessToken))?; + + if !access_token.is_valid(clock.now()) { + return Err(RouteError::InvalidToken(TokenType::AccessToken)); + } + + let session = repo + .personal_session() + .lookup(access_token.session_id) + .await? + .ok_or(RouteError::CantLoadPersonalSession(access_token.session_id))?; + + if !session.is_valid() { + return Err(RouteError::InvalidPersonalSession(session.id)); + } + + let actor_user = repo + .user() + .lookup(session.actor_user_id) + .await? + .ok_or(RouteError::CantLoadUser(session.actor_user_id))?; + + if !actor_user.is_valid() { + return Err(RouteError::InvalidUser(actor_user.id)); + } + + let client_id = match session.owner { + PersonalSessionOwner::User(owner_user_id) => { + let owner_user = repo + .user() + .lookup(owner_user_id) + .await? + .ok_or(RouteError::CantLoadUser(owner_user_id))?; + + if !owner_user.is_valid() { + return Err(RouteError::InvalidUser(owner_user.id)); + } + + None + } + PersonalSessionOwner::OAuth2Client(owner_client_id) => { + let owner_client = repo + .oauth2_client() + .lookup(owner_client_id) + .await? + .ok_or(RouteError::CantLoadOAuth2Client(owner_client_id))?; + + // OAuth2 clients are always valid if they're in the database + Some(owner_client.client_id.clone()) + } + }; + + activity_tracker + .record_personal_session(&clock, &session, ip) + .await; + + INTROSPECTION_COUNTER.add( + 1, + &[ + KeyValue::new(KIND, "personal_access_token"), + KeyValue::new(ACTIVE, true), + ], + ); + + let scope = normalize_scope(session.scope); + + IntrospectionResponse { + active: true, + scope: Some(scope), + client_id, + username: Some(actor_user.username), + token_type: Some(OAuthTokenTypeHint::AccessToken), + exp: access_token.expires_at, + expires_in: access_token + .expires_at + .map(|expires_at| expires_at.signed_duration_since(clock.now())), + iat: Some(access_token.created_at), + nbf: Some(access_token.created_at), + sub: Some(actor_user.sub), + aud: None, + iss: None, + jti: None, + device_id: None, + } + } }; repo.save().await?; @@ -636,7 +743,9 @@ pub(crate) async fn post( mod tests { use chrono::Duration; use hyper::{Request, StatusCode}; - use mas_data_model::{AccessToken, Clock, RefreshToken}; + use mas_data_model::{ + AccessToken, Clock, RefreshToken, TokenType, personal::session::PersonalSessionOwner, + }; use mas_iana::oauth::OAuthTokenTypeHint; use mas_matrix::{HomeserverConnection, MockHomeserverConnection, ProvisionRequest}; use mas_router::{OAuth2Introspection, OAuth2RegistrationEndpoint, SimpleRoute}; @@ -1069,4 +1178,125 @@ mod tests { let response: ClientError = response.json(); assert_eq!(response.error, ClientErrorCode::AccessDenied); } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_introspect_personal_access_tokens(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + // Provision a client which will be used to do introspection requests + let request = Request::post(OAuth2RegistrationEndpoint::PATH).json(json!({ + "client_uri": "https://introspecting.com/", + "grant_types": [], + "token_endpoint_auth_method": "client_secret_basic", + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let client: ClientRegistrationResponse = response.json(); + let introspecting_client_id = client.client_id; + let introspecting_client_secret = client.client_secret.unwrap(); + + let mut repo = state.repository().await.unwrap(); + + // Provision an owner user (who provisions the personal session) + let owner_user = repo + .user() + .add(&mut state.rng(), &state.clock, "admin".to_owned()) + .await + .unwrap(); + + // Provision an actor user (which the token represents) + let actor_user = repo + .user() + .add(&mut state.rng(), &state.clock, "bruce".to_owned()) + .await + .unwrap(); + + // admin creates a personal session to control bruce's account + let personal_session = repo + .personal_session() + .add( + &mut state.rng(), + &state.clock, + PersonalSessionOwner::User(owner_user.id), + &actor_user, + "Test Personal Access Token".to_owned(), + Scope::from_iter([OPENID]), + ) + .await + .unwrap(); + + // Generate a personal access token with proper token format + let token_string = TokenType::PersonalAccessToken.generate(&mut state.rng()); + let _personal_access_token = repo + .personal_access_token() + .add( + &mut state.rng(), + &state.clock, + &personal_session, + &token_string, + Some(Duration::try_hours(1).unwrap()), + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Now that we have a personal access token, we can introspect it + let request = Request::post(OAuth2Introspection::PATH) + .basic_auth(&introspecting_client_id, &introspecting_client_secret) + .form(json!({ "token": token_string })); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: IntrospectionResponse = response.json(); + assert!(response.active); + // Actor user + assert_eq!(response.username, Some("bruce".to_owned())); + // Not owned by a client + assert_eq!(response.client_id, None); + assert_eq!(response.token_type, Some(OAuthTokenTypeHint::AccessToken)); + assert_eq!(response.scope, Some(Scope::from_iter([OPENID]))); + + // Do the same request, but with a token_type_hint + let last_active = state.clock.now(); + let request = Request::post(OAuth2Introspection::PATH) + .basic_auth(&introspecting_client_id, &introspecting_client_secret) + .form(json!({"token": token_string, "token_type_hint": "access_token"})); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: IntrospectionResponse = response.json(); + assert!(response.active); + + // Do the same request, but with the wrong token_type_hint + let request = Request::post(OAuth2Introspection::PATH) + .basic_auth(&introspecting_client_id, &introspecting_client_secret) + .form(json!({"token": token_string, "token_type_hint": "refresh_token"})); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: IntrospectionResponse = response.json(); + assert!(!response.active); // It shouldn't be active with wrong hint + + // Advance the clock to invalidate the access token + state.clock.advance(Duration::try_hours(2).unwrap()); + + let request = Request::post(OAuth2Introspection::PATH) + .basic_auth(&introspecting_client_id, &introspecting_client_secret) + .form(json!({ "token": token_string })); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: IntrospectionResponse = response.json(); + assert!(!response.active); // It shouldn't be active anymore + + state.activity_tracker.flush().await; + let mut repo = state.repository().await.unwrap(); + let session = repo + .personal_session() + .lookup(personal_session.id) + .await + .unwrap() + .unwrap(); + assert_eq!(session.last_active_at, Some(last_active)); + repo.save().await.unwrap(); + } } diff --git a/crates/handlers/src/oauth2/webfinger.rs b/crates/handlers/src/oauth2/webfinger.rs index 8289e495c..489a8e9ef 100644 --- a/crates/handlers/src/oauth2/webfinger.rs +++ b/crates/handlers/src/oauth2/webfinger.rs @@ -4,12 +4,8 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. -use axum::{ - Json, - extract::{Query, State}, - response::IntoResponse, -}; -use axum_extra::typed_header::TypedHeader; +use axum::{Json, extract::State, response::IntoResponse}; +use axum_extra::{extract::Query, typed_header::TypedHeader}; use headers::ContentType; use mas_router::UrlBuilder; use oauth2_types::webfinger::WebFingerResponse; diff --git a/crates/handlers/src/passwords.rs b/crates/handlers/src/passwords.rs index 6f32f77f9..6071cf730 100644 --- a/crates/handlers/src/passwords.rs +++ b/crates/handlers/src/passwords.rs @@ -49,6 +49,11 @@ impl PasswordVerificationResult { Self::Failure => PasswordVerificationResult::Failure, } } + + #[must_use] + pub fn is_success(&self) -> bool { + matches!(self, Self::Success(_)) + } } impl From for PasswordVerificationResult<()> { diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index 15df95bad..2f3903f6d 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -28,6 +28,7 @@ use mas_axum_utils::{ cookies::{CookieJar, CookieManager}, }; use mas_config::RateLimitingConfig; +<<<<<<< HEAD use mas_data_model::{ BoxClock, BoxRng, @@ -37,6 +38,9 @@ use mas_data_model::{ //:tchap:end clock::MockClock, }; +======= +use mas_data_model::{AppVersion, BoxClock, BoxRng, SiteConfig, clock::MockClock}; +>>>>>>> v1.6.0 use mas_email::{MailTransport, Mailer}; use mas_i18n::Translator; use mas_keystore::{Encrypter, JsonWebKey, JsonWebKeySet, Keystore, PrivateKey}; @@ -151,6 +155,7 @@ pub fn test_site_config() -> SiteConfig { email_change_allowed: true, displayname_change_allowed: true, password_change_allowed: true, + password_registration_email_required: true, account_recovery_allowed: true, account_deactivation_allowed: true, captcha: None, @@ -186,6 +191,8 @@ impl TestState { workspace_root.join("translations"), site_config.templates_branding(), site_config.templates_features(), + // Strict mode in testing + true, ) .await?; @@ -593,6 +600,7 @@ impl FromRef for reqwest::Client { } } +<<<<<<< HEAD //:tchap: impl FromRef for TchapConfig { fn from_ref(input: &TestState) -> Self { @@ -600,6 +608,13 @@ impl FromRef for TchapConfig { } } //:tchap:end +======= +impl FromRef for AppVersion { + fn from_ref(_input: &TestState) -> Self { + AppVersion("v0.0.0-test") + } +} +>>>>>>> v1.6.0 impl FromRequestParts for ActivityTracker { type Rejection = Infallible; diff --git a/crates/handlers/src/upstream_oauth2/authorize.rs b/crates/handlers/src/upstream_oauth2/authorize.rs index 8d66c2ba5..8749f3c3d 100644 --- a/crates/handlers/src/upstream_oauth2/authorize.rs +++ b/crates/handlers/src/upstream_oauth2/authorize.rs @@ -5,9 +5,10 @@ // Please see LICENSE files in the repository root for full details. use axum::{ - extract::{Path, Query, State}, + extract::{Path, State}, response::{IntoResponse, Redirect}, }; +use axum_extra::extract::Query; use hyper::StatusCode; use mas_axum_utils::{GenericError, InternalError, cookies::CookieJar}; use mas_data_model::{BoxClock, BoxRng, UpstreamOAuthProvider}; diff --git a/crates/handlers/src/upstream_oauth2/backchannel_logout.rs b/crates/handlers/src/upstream_oauth2/backchannel_logout.rs index 76a7b574c..63454741c 100644 --- a/crates/handlers/src/upstream_oauth2/backchannel_logout.rs +++ b/crates/handlers/src/upstream_oauth2/backchannel_logout.rs @@ -267,9 +267,9 @@ pub(crate) async fn post( .browser_session() .list(browser_session_filter, cursor) .await?; - for browser_session in browser_sessions.edges { - user_ids.insert(browser_session.user.id); - cursor = cursor.after(browser_session.id); + for edge in browser_sessions.edges { + user_ids.insert(edge.node.user.id); + cursor = cursor.after(edge.cursor); } if !browser_sessions.has_next_page { diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index 2b6077d70..5b39041b9 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -1349,9 +1349,9 @@ mod tests { .list(UserEmailFilter::new().for_user(&user), Pagination::first(1)) .await .unwrap(); - let email = page.edges.first().expect("email exists"); + let edge = page.edges.first().expect("email exists"); - assert_eq!(email.email, "john@example.com"); + assert_eq!(edge.node.email, "john@example.com"); } #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] diff --git a/crates/handlers/src/views/app.rs b/crates/handlers/src/views/app.rs index 38c93bac4..4ae5f5222 100644 --- a/crates/handlers/src/views/app.rs +++ b/crates/handlers/src/views/app.rs @@ -5,9 +5,10 @@ // Please see LICENSE files in the repository root for full details. use axum::{ - extract::{Query, State}, + extract::State, response::{Html, IntoResponse}, }; +use axum_extra::extract::Query; use mas_axum_utils::{InternalError, cookies::CookieJar}; use mas_data_model::{BoxClock, BoxRng}; use mas_router::{PostAuthAction, UrlBuilder}; diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index 57091e5fc..72e1566fe 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -7,10 +7,10 @@ use std::sync::{Arc, LazyLock}; use axum::{ - extract::{Form, Query, State}, + extract::{Form, State}, response::{Html, IntoResponse, Response}, }; -use axum_extra::typed_header::TypedHeader; +use axum_extra::{extract::Query, typed_header::TypedHeader}; use hyper::StatusCode; use mas_axum_utils::{ InternalError, SessionInfoExt, diff --git a/crates/handlers/src/views/register/mod.rs b/crates/handlers/src/views/register/mod.rs index 41ee18ad6..ad7867a39 100644 --- a/crates/handlers/src/views/register/mod.rs +++ b/crates/handlers/src/views/register/mod.rs @@ -4,9 +4,10 @@ // Please see LICENSE files in the repository root for full details. use axum::{ - extract::{Query, State}, + extract::State, response::{Html, IntoResponse, Response}, }; +use axum_extra::extract::Query; use mas_axum_utils::{InternalError, SessionInfoExt, cookies::CookieJar, csrf::CsrfExt as _}; use mas_data_model::{BoxClock, BoxRng, SiteConfig}; use mas_router::{PasswordRegister, UpstreamOAuth2Authorize, UrlBuilder}; diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index f1d8bb8e1..8dcfe004a 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -7,10 +7,10 @@ use std::{str::FromStr, sync::Arc}; use axum::{ - extract::{Form, Query, State}, + extract::{Form, State}, response::{Html, IntoResponse, Response}, }; -use axum_extra::typed_header::TypedHeader; +use axum_extra::{extract::Query, typed_header::TypedHeader}; use hyper::StatusCode; use lettre::Address; use mas_axum_utils::{ @@ -48,6 +48,7 @@ use crate::{ #[derive(Debug, Deserialize, Serialize)] pub(crate) struct RegisterForm { username: String, + #[serde(default)] email: String, password: String, password_confirm: String, @@ -207,9 +208,16 @@ pub(crate) async fn post( .await .is_ok(); + let state = form.to_form_state(); + + // The email form is only shown if the server requires it + let email = site_config + .password_registration_email_required + .then_some(form.email); + // Validate the form let state = { - let mut state = form.to_form_state(); + let mut state = state; if !passed_captcha { state.add_error_on_form(FormError::Captcha); @@ -273,13 +281,15 @@ pub(crate) async fn post( homeserver_denied_username = true; } - // Note that we don't check here if the email is already taken here, as - // we don't want to leak the information about other users. Instead, we will - // show an error message once the user confirmed their email address. - if form.email.is_empty() { - state.add_error_on_field(RegisterFormField::Email, FieldError::Required); - } else if Address::from_str(&form.email).is_err() { - state.add_error_on_field(RegisterFormField::Email, FieldError::Invalid); + if let Some(email) = &email { + // Note that we don't check here if the email is already taken here, as + // we don't want to leak the information about other users. Instead, we will + // show an error message once the user confirmed their email address. + if email.is_empty() { + state.add_error_on_field(RegisterFormField::Email, FieldError::Required); + } else if Address::from_str(email).is_err() { + state.add_error_on_field(RegisterFormField::Email, FieldError::Invalid); + } } if form.password.is_empty() { @@ -318,7 +328,7 @@ pub(crate) async fn post( .evaluate_register(mas_policy::RegisterInput { registration_method: mas_policy::RegistrationMethod::Password, username: &form.username, - email: Some(&form.email), + email: email.as_deref(), requester: mas_policy::Requester { ip_address: activity_tracker.ip(), user_agent: user_agent.clone(), @@ -373,7 +383,9 @@ pub(crate) async fn post( state.add_error_on_form(FormError::RateLimitExceeded); } - if let Err(e) = limiter.check_email_authentication_email(requester, &form.email) { + if let Some(email) = &email + && let Err(e) = limiter.check_email_authentication_email(requester, email) + { tracing::warn!(error = &e as &dyn std::error::Error); state.add_error_on_form(FormError::RateLimitExceeded); } @@ -421,6 +433,7 @@ pub(crate) async fn post( registration }; +<<<<<<< HEAD //:tchap: set display name automatically - skip display name page let maybe_display_name = Some(email_to_display_name(&form.email)); @@ -438,20 +451,30 @@ pub(crate) async fn post( .user_email() .add_authentication_for_registration(&mut rng, &clock, form.email, ®istration) .await?; +======= + let registration = if let Some(email) = email { + // Create a new user email authentication session + let user_email_authentication = repo + .user_email() + .add_authentication_for_registration(&mut rng, &clock, email, ®istration) + .await?; +>>>>>>> v1.6.0 + + // Schedule a job to verify the email + repo.queue_job() + .schedule_job( + &mut rng, + &clock, + SendEmailAuthenticationCodeJob::new(&user_email_authentication, locale.to_string()), + ) + .await?; - // Schedule a job to verify the email - repo.queue_job() - .schedule_job( - &mut rng, - &clock, - SendEmailAuthenticationCodeJob::new(&user_email_authentication, locale.to_string()), - ) - .await?; - - let registration = repo - .user_registration() - .set_email_authentication(registration, &user_email_authentication) - .await?; + repo.user_registration() + .set_email_authentication(registration, &user_email_authentication) + .await? + } else { + registration + }; // Hash the password let password = Zeroizing::new(form.password); @@ -986,4 +1009,319 @@ mod tests { response.assert_status(StatusCode::OK); assert!(response.body().contains("This username is already taken")); } + + /// Test registration without email when email is not required + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_without_email_when_not_required(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_registration_email_required: false, + ..test_site_config() + }, + ) + .await + .unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = + Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + // Extract the CSRF token from the response body + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('\"') + .next() + .unwrap(); + + // Submit the registration form without email + let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) + .form(serde_json::json!({ + "csrf": csrf_token, + "username": "alice", + "password": "correcthorsebatterystaple", + "password_confirm": "correcthorsebatterystaple", + "accept_terms": "on", + })); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::SEE_OTHER); + let location = response.headers().get(LOCATION).unwrap(); + + // The handler redirects with the ID as the second to last portion of the path + let id = location + .to_str() + .unwrap() + .rsplit('/') + .nth(1) + .unwrap() + .parse() + .unwrap(); + + // There should be a new registration in the database + let mut repo = state.repository().await.unwrap(); + let registration = repo.user_registration().lookup(id).await.unwrap().unwrap(); + assert_eq!(registration.username, "alice".to_owned()); + assert!(registration.password.is_some()); + // Email authentication should be None when email is not required and not + // provided + assert!(registration.email_authentication_id.is_none()); + } + + /// Test registration with valid email when email is not required + /// (email input is ignored completely when not required) + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_with_email_when_not_required(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_registration_email_required: false, + ..test_site_config() + }, + ) + .await + .unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = + Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + // Extract the CSRF token from the response body + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('\"') + .next() + .unwrap(); + + // Submit the registration form with valid email + let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) + .form(serde_json::json!({ + "csrf": csrf_token, + "username": "charlie", + "email": "charlie@example.com", + "password": "correcthorsebatterystaple", + "password_confirm": "correcthorsebatterystaple", + "accept_terms": "on", + })); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::SEE_OTHER); + let location = response.headers().get(LOCATION).unwrap(); + + // The handler redirects with the ID as the second to last portion of the path + let id = location + .to_str() + .unwrap() + .rsplit('/') + .nth(1) + .unwrap() + .parse() + .unwrap(); + + // There should be a new registration in the database + let mut repo = state.repository().await.unwrap(); + let registration = repo.user_registration().lookup(id).await.unwrap().unwrap(); + assert_eq!(registration.username, "charlie".to_owned()); + assert!(registration.password.is_some()); + + // Email authentication should be None when email is not required + // (email input is completely ignored in this case) + assert!(registration.email_authentication_id.is_none()); + } + + /// Test registration fails when email is required but not provided + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_fails_without_email_when_required(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_registration_email_required: true, + ..test_site_config() + }, + ) + .await + .unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = + Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + // Extract the CSRF token from the response body + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('\"') + .next() + .unwrap(); + + // Submit the registration form without email + let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) + .form(serde_json::json!({ + "csrf": csrf_token, + "username": "david", + "password": "correcthorsebatterystaple", + "password_confirm": "correcthorsebatterystaple", + "accept_terms": "on", + })); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + + // Check that the response contains an error about the email field + let body = response.body(); + assert!(body.contains("email") || body.contains("Email")); + + // Ensure no registration was created + let mut repo = state.repository().await.unwrap(); + let user_exists = repo.user().exists("david").await.unwrap(); + assert!(!user_exists); + } + + /// Test registration fails when email is required but empty + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_fails_with_empty_email_when_required(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_registration_email_required: true, + ..test_site_config() + }, + ) + .await + .unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = + Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + // Extract the CSRF token from the response body + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('\"') + .next() + .unwrap(); + + // Submit the registration form with empty email + let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) + .form(serde_json::json!({ + "csrf": csrf_token, + "username": "eve", + "email": "", + "password": "correcthorsebatterystaple", + "password_confirm": "correcthorsebatterystaple", + "accept_terms": "on", + })); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + + // Check that the response contains an error about the email field + let body = response.body(); + assert!(body.contains("email") || body.contains("Email")); + + // Ensure no registration was created + let mut repo = state.repository().await.unwrap(); + let user_exists = repo.user().exists("eve").await.unwrap(); + assert!(!user_exists); + } + + /// Test registration fails with invalid email when email is required + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_fails_with_invalid_email_when_required(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_registration_email_required: true, + ..test_site_config() + }, + ) + .await + .unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = + Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + // Extract the CSRF token from the response body + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('\"') + .next() + .unwrap(); + + // Submit the registration form with invalid email + let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) + .form(serde_json::json!({ + "csrf": csrf_token, + "username": "grace", + "email": "not-an-email", + "password": "correcthorsebatterystaple", + "password_confirm": "correcthorsebatterystaple", + "accept_terms": "on", + })); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + + // Check that the response contains an error about the email field + let body = response.body(); + assert!(body.contains("email") || body.contains("Email")); + + // Ensure no registration was created + let mut repo = state.repository().await.unwrap(); + let user_exists = repo.user().exists("grace").await.unwrap(); + assert!(!user_exists); + } } diff --git a/crates/handlers/src/views/register/steps/finish.rs b/crates/handlers/src/views/register/steps/finish.rs index de7a537b5..e1ed8a3f0 100644 --- a/crates/handlers/src/views/register/steps/finish.rs +++ b/crates/handlers/src/views/register/steps/finish.rs @@ -151,52 +151,62 @@ pub(crate) async fn get( None }; - // For now, we require an email address on the registration, but this might - // change in the future - let email_authentication_id = registration - .email_authentication_id - .context("No email authentication started for this registration") - .map_err(InternalError::from_anyhow)?; - let email_authentication = repo - .user_email() - .lookup_authentication(email_authentication_id) - .await? - .context("Could not load the email authentication") - .map_err(InternalError::from_anyhow)?; - - // Check that the email authentication has been completed - if email_authentication.completed_at.is_none() { - return Ok(( - cookie_jar, - url_builder.redirect(&mas_router::RegisterVerifyEmail::new(id)), - ) - .into_response()); - } - - // Check that the email address isn't already used - // It is important to do that here, as we we're not checking during the - // registration, because we don't want to disclose whether an email is - // already being used or not before we verified it - if repo - .user_email() - .count(UserEmailFilter::new().for_email(&email_authentication.email)) - .await? - > 0 + // If there is an email authentication, we need to check that the email + // address was verified. If there is no email authentication attached, we + // need to make sure the server doesn't require it + let email_authentication = if let Some(email_authentication_id) = + registration.email_authentication_id { - let action = registration - .post_auth_action - .map(serde_json::from_value) - .transpose()?; + let email_authentication = repo + .user_email() + .lookup_authentication(email_authentication_id) + .await? + .context("Could not load the email authentication") + .map_err(InternalError::from_anyhow)?; + + // Check that the email authentication has been completed + if email_authentication.completed_at.is_none() { + return Ok(( + cookie_jar, + url_builder.redirect(&mas_router::RegisterVerifyEmail::new(id)), + ) + .into_response()); + } - let ctx = RegisterStepsEmailInUseContext::new(email_authentication.email, action) - .with_language(lang); + // Check that the email address isn't already used + // It is important to do that here, as we we're not checking during the + // registration, because we don't want to disclose whether an email is + // already being used or not before we verified it + if repo + .user_email() + .count(UserEmailFilter::new().for_email(&email_authentication.email)) + .await? + > 0 + { + let action = registration + .post_auth_action + .map(serde_json::from_value) + .transpose()?; + + let ctx = RegisterStepsEmailInUseContext::new(email_authentication.email, action) + .with_language(lang); - return Ok(( - cookie_jar, - Html(templates.render_register_steps_email_in_use(&ctx)?), - ) - .into_response()); - } + return Ok(( + cookie_jar, + Html(templates.render_register_steps_email_in_use(&ctx)?), + ) + .into_response()); + } + + Some(email_authentication) + } else if site_config.password_registration_email_required { + // This could only happen in theory during a configuration change + return Err(InternalError::from_anyhow(anyhow::anyhow!( + "Server requires an email address to complete the registration, but no email authentication was attached to the user registration" + ))); + } else { + None + }; // Check that the display name is set if registration.display_name.is_none() { @@ -236,9 +246,11 @@ pub(crate) async fn get( .add(&mut rng, &clock, &user, user_agent) .await?; - repo.user_email() - .add(&mut rng, &clock, &user, email_authentication.email) - .await?; + if let Some(email_authentication) = email_authentication { + repo.user_email() + .add(&mut rng, &clock, &user, email_authentication.email) + .await?; + } if let Some(password) = registration.password { let user_password = repo diff --git a/crates/oauth2-types/src/requests.rs b/crates/oauth2-types/src/requests.rs index ac0770411..4c9f1117d 100644 --- a/crates/oauth2-types/src/requests.rs +++ b/crates/oauth2-types/src/requests.rs @@ -86,11 +86,13 @@ impl core::str::FromStr for ResponseMode { Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Clone, SerializeDisplay, DeserializeFromStr, )] #[non_exhaustive] +#[derive(Default)] pub enum Display { /// The Authorization Server should display the authentication and consent /// UI consistent with a full User Agent page view. /// /// This is the default display mode. + #[default] Page, /// The Authorization Server should display the authentication and consent @@ -135,12 +137,6 @@ impl core::str::FromStr for Display { } } -impl Default for Display { - fn default() -> Self { - Self::Page - } -} - /// Value that specifies whether the Authorization Server prompts the End-User /// for reauthentication and consent. /// @@ -807,6 +803,7 @@ pub struct IntrospectionResponse { pub jti: Option, /// MAS extension: explicit device ID + /// Only used for compatibility access and refresh tokens. pub device_id: Option, } diff --git a/crates/storage-pg/.sqlx/query-06d67595eeef23d5f2773632e0956577d98074e244a35c0d3be24bc18d9d0daa.json b/crates/storage-pg/.sqlx/query-06d67595eeef23d5f2773632e0956577d98074e244a35c0d3be24bc18d9d0daa.json new file mode 100644 index 000000000..55509569c --- /dev/null +++ b/crates/storage-pg/.sqlx/query-06d67595eeef23d5f2773632e0956577d98074e244a35c0d3be24bc18d9d0daa.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE personal_sessions\n SET revoked_at = $2\n WHERE personal_session_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "06d67595eeef23d5f2773632e0956577d98074e244a35c0d3be24bc18d9d0daa" +} diff --git a/crates/storage-pg/.sqlx/query-0e45995714e60b71e0f0158500a63aa46225245a04d1c7bc24b5275c44a6d58d.json b/crates/storage-pg/.sqlx/query-0e45995714e60b71e0f0158500a63aa46225245a04d1c7bc24b5275c44a6d58d.json new file mode 100644 index 000000000..5bba6548d --- /dev/null +++ b/crates/storage-pg/.sqlx/query-0e45995714e60b71e0f0158500a63aa46225245a04d1c7bc24b5275c44a6d58d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE personal_access_tokens\n SET revoked_at = $2\n WHERE personal_access_token_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "0e45995714e60b71e0f0158500a63aa46225245a04d1c7bc24b5275c44a6d58d" +} diff --git a/crates/storage-pg/.sqlx/query-109f0c859e123966462f1001aef550e4e12d1778474aba72762d9aa093d21ee2.json b/crates/storage-pg/.sqlx/query-109f0c859e123966462f1001aef550e4e12d1778474aba72762d9aa093d21ee2.json new file mode 100644 index 000000000..83400921a --- /dev/null +++ b/crates/storage-pg/.sqlx/query-109f0c859e123966462f1001aef550e4e12d1778474aba72762d9aa093d21ee2.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO personal_sessions\n ( personal_session_id\n , owner_user_id\n , owner_oauth2_client_id\n , actor_user_id\n , human_name\n , scope_list\n , created_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Uuid", + "Text", + "TextArray", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "109f0c859e123966462f1001aef550e4e12d1778474aba72762d9aa093d21ee2" +} diff --git a/crates/storage-pg/.sqlx/query-2a61003da3655158e6a261d91fdff670f1b4ba3c56605c53e2b905d7ec38c8be.json b/crates/storage-pg/.sqlx/query-2a61003da3655158e6a261d91fdff670f1b4ba3c56605c53e2b905d7ec38c8be.json new file mode 100644 index 000000000..21a67060b --- /dev/null +++ b/crates/storage-pg/.sqlx/query-2a61003da3655158e6a261d91fdff670f1b4ba3c56605c53e2b905d7ec38c8be.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM personal_access_tokens\n WHERE personal_session_id IN (\n SELECT personal_session_id\n FROM personal_sessions\n WHERE owner_oauth2_client_id = $1\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "2a61003da3655158e6a261d91fdff670f1b4ba3c56605c53e2b905d7ec38c8be" +} diff --git a/crates/storage-pg/.sqlx/query-64b6e274e2bed6814f5ae41ddf57093589f7d1b2b8458521b635546b8012041e.json b/crates/storage-pg/.sqlx/query-64b6e274e2bed6814f5ae41ddf57093589f7d1b2b8458521b635546b8012041e.json new file mode 100644 index 000000000..6b2e85bf1 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-64b6e274e2bed6814f5ae41ddf57093589f7d1b2b8458521b635546b8012041e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE personal_sessions\n SET last_active_at = GREATEST(t.last_active_at, personal_sessions.last_active_at)\n , last_active_ip = COALESCE(t.last_active_ip, personal_sessions.last_active_ip)\n FROM (\n SELECT *\n FROM UNNEST($1::uuid[], $2::timestamptz[], $3::inet[])\n AS t(personal_session_id, last_active_at, last_active_ip)\n ) AS t\n WHERE personal_sessions.personal_session_id = t.personal_session_id\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "TimestamptzArray", + "InetArray" + ] + }, + "nullable": [] + }, + "hash": "64b6e274e2bed6814f5ae41ddf57093589f7d1b2b8458521b635546b8012041e" +} diff --git a/crates/storage-pg/.sqlx/query-90875bdd2f75cdf0dc3f48dc2516f5c701411387c939f6b8a3478b41b3de4f20.json b/crates/storage-pg/.sqlx/query-90875bdd2f75cdf0dc3f48dc2516f5c701411387c939f6b8a3478b41b3de4f20.json new file mode 100644 index 000000000..66aab4ee6 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-90875bdd2f75cdf0dc3f48dc2516f5c701411387c939f6b8a3478b41b3de4f20.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT personal_access_token_id\n , personal_session_id\n , created_at\n , expires_at\n , revoked_at\n\n FROM personal_access_tokens\n\n WHERE access_token_sha256 = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "personal_access_token_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "personal_session_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "90875bdd2f75cdf0dc3f48dc2516f5c701411387c939f6b8a3478b41b3de4f20" +} diff --git a/crates/storage-pg/.sqlx/query-9e8152d445f9996b221ad3690ba982ad01035296bf4539ca5620a043924a7292.json b/crates/storage-pg/.sqlx/query-9e8152d445f9996b221ad3690ba982ad01035296bf4539ca5620a043924a7292.json new file mode 100644 index 000000000..0a838d1a5 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-9e8152d445f9996b221ad3690ba982ad01035296bf4539ca5620a043924a7292.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE personal_access_tokens\n SET revoked_at = $2\n WHERE personal_session_id = $1 AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "9e8152d445f9996b221ad3690ba982ad01035296bf4539ca5620a043924a7292" +} diff --git a/crates/storage-pg/.sqlx/query-a0be6c56e470382b9470df414497e260ba8911123744980e24a52bc9b95bd056.json b/crates/storage-pg/.sqlx/query-a0be6c56e470382b9470df414497e260ba8911123744980e24a52bc9b95bd056.json new file mode 100644 index 000000000..3542f8481 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-a0be6c56e470382b9470df414497e260ba8911123744980e24a52bc9b95bd056.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO personal_access_tokens\n (personal_access_token_id, personal_session_id, access_token_sha256, created_at, expires_at)\n VALUES ($1, $2, $3, $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Bytea", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "a0be6c56e470382b9470df414497e260ba8911123744980e24a52bc9b95bd056" +} diff --git a/crates/storage-pg/.sqlx/query-d02248136aa6b27636814dee4e0bc38395ab6c6fdf979616fa16fc490897cee3.json b/crates/storage-pg/.sqlx/query-d02248136aa6b27636814dee4e0bc38395ab6c6fdf979616fa16fc490897cee3.json new file mode 100644 index 000000000..99df8e139 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-d02248136aa6b27636814dee4e0bc38395ab6c6fdf979616fa16fc490897cee3.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT personal_access_token_id\n , personal_session_id\n , created_at\n , expires_at\n , revoked_at\n\n FROM personal_access_tokens\n\n WHERE personal_session_id = $1\n AND revoked_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "personal_access_token_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "personal_session_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "d02248136aa6b27636814dee4e0bc38395ab6c6fdf979616fa16fc490897cee3" +} diff --git a/crates/storage-pg/.sqlx/query-dca9b361c4409b14498b85f192b0034201575a49e0240ac6715b55ad8d381d0e.json b/crates/storage-pg/.sqlx/query-dca9b361c4409b14498b85f192b0034201575a49e0240ac6715b55ad8d381d0e.json new file mode 100644 index 000000000..39447cd10 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-dca9b361c4409b14498b85f192b0034201575a49e0240ac6715b55ad8d381d0e.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM personal_sessions\n WHERE owner_oauth2_client_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "dca9b361c4409b14498b85f192b0034201575a49e0240ac6715b55ad8d381d0e" +} diff --git a/crates/storage-pg/.sqlx/query-e1746b33c2f0d10f26332195f78e1ef2f192ca66f8000d1385626154e5ce4f7e.json b/crates/storage-pg/.sqlx/query-e1746b33c2f0d10f26332195f78e1ef2f192ca66f8000d1385626154e5ce4f7e.json new file mode 100644 index 000000000..2112e7603 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-e1746b33c2f0d10f26332195f78e1ef2f192ca66f8000d1385626154e5ce4f7e.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT personal_access_token_id\n , personal_session_id\n , created_at\n , expires_at\n , revoked_at\n\n FROM personal_access_tokens\n\n WHERE personal_access_token_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "personal_access_token_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "personal_session_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "e1746b33c2f0d10f26332195f78e1ef2f192ca66f8000d1385626154e5ce4f7e" +} diff --git a/crates/storage-pg/.sqlx/query-fcd8b4b9e003d1540357c6bf1ff9c715560d011d4c01112703a9c046170c84f1.json b/crates/storage-pg/.sqlx/query-fcd8b4b9e003d1540357c6bf1ff9c715560d011d4c01112703a9c046170c84f1.json index f5503fa0e..ef1ac0372 100644 --- a/crates/storage-pg/.sqlx/query-fcd8b4b9e003d1540357c6bf1ff9c715560d011d4c01112703a9c046170c84f1.json +++ b/crates/storage-pg/.sqlx/query-fcd8b4b9e003d1540357c6bf1ff9c715560d011d4c01112703a9c046170c84f1.json @@ -23,7 +23,7 @@ "Left": [] }, "nullable": [ - false, + true, true, null ] diff --git a/crates/storage-pg/.sqlx/query-fd32368fa6cd16a9704cdea54f7729681d450669563dd1178c492ffce51e5ff2.json b/crates/storage-pg/.sqlx/query-fd32368fa6cd16a9704cdea54f7729681d450669563dd1178c492ffce51e5ff2.json new file mode 100644 index 000000000..b46904ccb --- /dev/null +++ b/crates/storage-pg/.sqlx/query-fd32368fa6cd16a9704cdea54f7729681d450669563dd1178c492ffce51e5ff2.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT personal_session_id\n , owner_user_id\n , owner_oauth2_client_id\n , actor_user_id\n , scope_list\n , created_at\n , revoked_at\n , human_name\n , last_active_at\n , last_active_ip as \"last_active_ip: IpAddr\"\n FROM personal_sessions\n\n WHERE personal_session_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "personal_session_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "owner_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "owner_oauth2_client_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "actor_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "scope_list", + "type_info": "TextArray" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "human_name", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "last_active_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "last_active_ip: IpAddr", + "type_info": "Inet" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true, + true, + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "fd32368fa6cd16a9704cdea54f7729681d450669563dd1178c492ffce51e5ff2" +} diff --git a/crates/storage-pg/Cargo.toml b/crates/storage-pg/Cargo.toml index 149e92fc6..8710ead70 100644 --- a/crates/storage-pg/Cargo.toml +++ b/crates/storage-pg/Cargo.toml @@ -27,6 +27,7 @@ rand.workspace = true sea-query-binder.workspace = true sea-query.workspace = true serde_json.workspace = true +sha2.workspace = true sqlx.workspace = true thiserror.workspace = true tracing.workspace = true diff --git a/crates/storage-pg/migrations/20250924132713_personal_access_tokens.sql b/crates/storage-pg/migrations/20250924132713_personal_access_tokens.sql new file mode 100644 index 000000000..0e113b156 --- /dev/null +++ b/crates/storage-pg/migrations/20250924132713_personal_access_tokens.sql @@ -0,0 +1,68 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +-- Please see LICENSE in the repository root for full details. + +-- A family of personal access tokens. This is a long-lived wrapper around the personal access tokens +-- themselves, allowing tokens to be regenerated whilst still retaining a persistent identifier for them. +CREATE TABLE personal_sessions ( + personal_session_id UUID NOT NULL PRIMARY KEY, + + -- If this session is owned by a user, the ID of the user. + -- Null otherwise. + owner_user_id UUID REFERENCES users(user_id), + + -- If this session is owned by an OAuth 2 Client (via Client Credentials grant), + -- the ID of the owning client. + -- Null otherwise. + owner_oauth2_client_id UUID REFERENCES oauth2_clients(oauth2_client_id), + + actor_user_id UUID NOT NULL REFERENCES users(user_id), + -- A human-readable label, intended to describe what the session is for. + human_name TEXT NOT NULL, + -- The OAuth2 scopes for the session, identical to OAuth2 sessions. + -- May include a device ID, but this is optional (sessions can be deviceless). + scope_list TEXT[] NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + -- If set, none of the tokens will be valid anymore. + revoked_at TIMESTAMP WITH TIME ZONE, + last_active_at TIMESTAMP WITH TIME ZONE, + last_active_ip INET, + + -- There must be exactly one owner. + CONSTRAINT personal_sessions_exactly_one_owner CHECK ((owner_user_id IS NULL) <> (owner_oauth2_client_id IS NULL)) +); + +-- Individual tokens. +CREATE TABLE personal_access_tokens ( + personal_access_token_id UUID NOT NULL PRIMARY KEY, + -- The session this access token belongs to. + personal_session_id UUID NOT NULL REFERENCES personal_sessions(personal_session_id), + -- SHA256 of the access token. + -- This is a lightweight measure to stop a database backup (or other + -- unauthorised read-only database access) escalating into real permissions + -- on a live system. + -- We could have used a hash with secret key, but this would no longer be + -- 'free' protection because it would need configuration (and introduce + -- potential issues with configuring it wrong). + -- This is currently inconsistent with other access token tables but it would + -- make sense to migrate those to match in the future. + access_token_sha256 BYTEA NOT NULL UNIQUE + -- A SHA256 hash is 32 bytes long + CHECK (octet_length(access_token_sha256) = 32), + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + -- If set, the token won't be valid after this time. + -- If not set, the token never automatically expires. + expires_at TIMESTAMP WITH TIME ZONE, + -- If set, this token is not valid anymore. + revoked_at TIMESTAMP WITH TIME ZONE +); + +-- Ensure we can only have one active personal access token in each family. +CREATE UNIQUE INDEX ON personal_access_tokens (personal_session_id) WHERE revoked_at IS NOT NULL; + +-- Add indices to satisfy foreign key backward checks +-- (and likely filter queries) +CREATE INDEX ON personal_sessions (owner_user_id) WHERE owner_user_id IS NOT NULL; +CREATE INDEX ON personal_sessions (owner_oauth2_client_id) WHERE owner_oauth2_client_id IS NOT NULL; +CREATE INDEX ON personal_sessions (actor_user_id); diff --git a/crates/storage-pg/migrations/20251023134634_personal_access_tokens_unique_fix.sql b/crates/storage-pg/migrations/20251023134634_personal_access_tokens_unique_fix.sql new file mode 100644 index 000000000..9274d16ac --- /dev/null +++ b/crates/storage-pg/migrations/20251023134634_personal_access_tokens_unique_fix.sql @@ -0,0 +1,14 @@ +-- Copyright 2025 Element Creations Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +-- Please see LICENSE in the repository root for full details. + + +-- Fix a faulty constraint. +-- The condition was incorrectly specified as `revoked_at IS NOT NULL` +-- when `revoked_at IS NULL` was meant. + +DROP INDEX personal_access_tokens_personal_session_id_idx; + +-- Ensure we can only have one active personal access token in each family. +CREATE UNIQUE INDEX ON personal_access_tokens (personal_session_id) WHERE revoked_at IS NULL; diff --git a/crates/storage-pg/src/app_session.rs b/crates/storage-pg/src/app_session.rs index 62f3979a9..4e12810cc 100644 --- a/crates/storage-pg/src/app_session.rs +++ b/crates/storage-pg/src/app_session.rs @@ -55,7 +55,9 @@ mod priv_ { use std::net::IpAddr; use chrono::{DateTime, Utc}; + use mas_storage::pagination::Node; use sea_query::enum_def; + use ulid::Ulid; use uuid::Uuid; #[derive(sqlx::FromRow)] @@ -77,6 +79,12 @@ mod priv_ { pub(super) last_active_at: Option>, pub(super) last_active_ip: Option, } + + impl Node for AppSessionLookup { + fn cursor(&self) -> Ulid { + self.cursor.into() + } + } } use priv_::{AppSessionLookup, AppSessionLookupIden}; @@ -592,13 +600,13 @@ mod tests { let full_list = repo.app_session().list(all, pagination).await.unwrap(); assert_eq!(full_list.edges.len(), 1); assert_eq!( - full_list.edges[0], + full_list.edges[0].node, AppSession::Compat(Box::new(compat_session.clone())) ); let active_list = repo.app_session().list(active, pagination).await.unwrap(); assert_eq!(active_list.edges.len(), 1); assert_eq!( - active_list.edges[0], + active_list.edges[0].node, AppSession::Compat(Box::new(compat_session.clone())) ); let finished_list = repo.app_session().list(finished, pagination).await.unwrap(); @@ -618,7 +626,7 @@ mod tests { let full_list = repo.app_session().list(all, pagination).await.unwrap(); assert_eq!(full_list.edges.len(), 1); assert_eq!( - full_list.edges[0], + full_list.edges[0].node, AppSession::Compat(Box::new(compat_session.clone())) ); let active_list = repo.app_session().list(active, pagination).await.unwrap(); @@ -626,7 +634,7 @@ mod tests { let finished_list = repo.app_session().list(finished, pagination).await.unwrap(); assert_eq!(finished_list.edges.len(), 1); assert_eq!( - finished_list.edges[0], + finished_list.edges[0].node, AppSession::Compat(Box::new(compat_session.clone())) ); @@ -680,25 +688,25 @@ mod tests { let full_list = repo.app_session().list(all, pagination).await.unwrap(); assert_eq!(full_list.edges.len(), 2); assert_eq!( - full_list.edges[0], + full_list.edges[0].node, AppSession::Compat(Box::new(compat_session.clone())) ); assert_eq!( - full_list.edges[1], + full_list.edges[1].node, AppSession::OAuth2(Box::new(oauth_session.clone())) ); let active_list = repo.app_session().list(active, pagination).await.unwrap(); assert_eq!(active_list.edges.len(), 1); assert_eq!( - active_list.edges[0], + active_list.edges[0].node, AppSession::OAuth2(Box::new(oauth_session.clone())) ); let finished_list = repo.app_session().list(finished, pagination).await.unwrap(); assert_eq!(finished_list.edges.len(), 1); assert_eq!( - finished_list.edges[0], + finished_list.edges[0].node, AppSession::Compat(Box::new(compat_session.clone())) ); @@ -716,11 +724,11 @@ mod tests { let full_list = repo.app_session().list(all, pagination).await.unwrap(); assert_eq!(full_list.edges.len(), 2); assert_eq!( - full_list.edges[0], + full_list.edges[0].node, AppSession::Compat(Box::new(compat_session.clone())) ); assert_eq!( - full_list.edges[1], + full_list.edges[1].node, AppSession::OAuth2(Box::new(oauth_session.clone())) ); @@ -730,11 +738,11 @@ mod tests { let finished_list = repo.app_session().list(finished, pagination).await.unwrap(); assert_eq!(finished_list.edges.len(), 2); assert_eq!( - finished_list.edges[0], + finished_list.edges[0].node, AppSession::Compat(Box::new(compat_session.clone())) ); assert_eq!( - full_list.edges[1], + full_list.edges[1].node, AppSession::OAuth2(Box::new(oauth_session.clone())) ); @@ -744,7 +752,7 @@ mod tests { let list = repo.app_session().list(filter, pagination).await.unwrap(); assert_eq!(list.edges.len(), 1); assert_eq!( - list.edges[0], + list.edges[0].node, AppSession::Compat(Box::new(compat_session.clone())) ); @@ -753,7 +761,7 @@ mod tests { let list = repo.app_session().list(filter, pagination).await.unwrap(); assert_eq!(list.edges.len(), 1); assert_eq!( - list.edges[0], + list.edges[0].node, AppSession::OAuth2(Box::new(oauth_session.clone())) ); diff --git a/crates/storage-pg/src/compat/mod.rs b/crates/storage-pg/src/compat/mod.rs index db190db71..d42c9b1af 100644 --- a/crates/storage-pg/src/compat/mod.rs +++ b/crates/storage-pg/src/compat/mod.rs @@ -92,14 +92,14 @@ mod tests { let full_list = repo.compat_session().list(all, pagination).await.unwrap(); assert_eq!(full_list.edges.len(), 1); - assert_eq!(full_list.edges[0].0.id, session.id); + assert_eq!(full_list.edges[0].node.0.id, session.id); let active_list = repo .compat_session() .list(active, pagination) .await .unwrap(); assert_eq!(active_list.edges.len(), 1); - assert_eq!(active_list.edges[0].0.id, session.id); + assert_eq!(active_list.edges[0].node.0.id, session.id); let finished_list = repo .compat_session() .list(finished, pagination) @@ -150,7 +150,7 @@ mod tests { .await .unwrap(); assert_eq!(list.edges.len(), 1); - let session_lookup = &list.edges[0].0; + let session_lookup = &list.edges[0].node.0; assert_eq!(session_lookup.id, session.id); assert_eq!(session_lookup.user_id, user.id); assert_eq!(session.device.as_ref().unwrap().as_str(), device_str); @@ -168,7 +168,7 @@ mod tests { let full_list = repo.compat_session().list(all, pagination).await.unwrap(); assert_eq!(full_list.edges.len(), 1); - assert_eq!(full_list.edges[0].0.id, session.id); + assert_eq!(full_list.edges[0].node.0.id, session.id); let active_list = repo .compat_session() .list(active, pagination) @@ -181,7 +181,7 @@ mod tests { .await .unwrap(); assert_eq!(finished_list.edges.len(), 1); - assert_eq!(finished_list.edges[0].0.id, session.id); + assert_eq!(finished_list.edges[0].node.0.id, session.id); // Reload the session and check again let session_lookup = repo @@ -260,14 +260,14 @@ mod tests { .await .unwrap(); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0].0.id, sso_login_session.id); + assert_eq!(list.edges[0].node.0.id, sso_login_session.id); let list = repo .compat_session() .list(unknown, pagination) .await .unwrap(); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0].0.id, unknown_session.id); + assert_eq!(list.edges[0].node.0.id, unknown_session.id); // Check that combining the two filters works // At this point, there is one active SSO login session and one finished unknown @@ -696,7 +696,8 @@ mod tests { // List all logins let logins = repo.compat_sso_login().list(all, pagination).await.unwrap(); assert!(!logins.has_next_page); - assert_eq!(logins.edges, vec![login.clone()]); + assert_eq!(logins.edges.len(), 1); + assert_eq!(logins.edges[0].node, login); // List the logins for the user let logins = repo @@ -705,7 +706,8 @@ mod tests { .await .unwrap(); assert!(!logins.has_next_page); - assert_eq!(logins.edges, vec![login.clone()]); + assert_eq!(logins.edges.len(), 1); + assert_eq!(logins.edges[0].node, login); // List only the pending logins for the user let logins = repo @@ -732,6 +734,7 @@ mod tests { .await .unwrap(); assert!(!logins.has_next_page); - assert_eq!(logins.edges, &[login]); + assert_eq!(logins.edges.len(), 1); + assert_eq!(logins.edges[0].node, login); } } diff --git a/crates/storage-pg/src/compat/session.rs b/crates/storage-pg/src/compat/session.rs index 4ba5ee726..0fb21c487 100644 --- a/crates/storage-pg/src/compat/session.rs +++ b/crates/storage-pg/src/compat/session.rs @@ -15,6 +15,7 @@ use mas_data_model::{ use mas_storage::{ Page, Pagination, compat::{CompatSessionFilter, CompatSessionRepository}, + pagination::Node, }; use rand::RngCore; use sea_query::{Expr, PostgresQueryBuilder, Query, enum_def}; @@ -59,6 +60,12 @@ struct CompatSessionLookup { last_active_ip: Option, } +impl Node for CompatSessionLookup { + fn cursor(&self) -> Ulid { + self.compat_session_id.into() + } +} + impl From for CompatSession { fn from(value: CompatSessionLookup) -> Self { let id = value.compat_session_id.into(); @@ -106,6 +113,12 @@ struct CompatSessionAndSsoLoginLookup { compat_sso_login_exchanged_at: Option>, } +impl Node for CompatSessionAndSsoLoginLookup { + fn cursor(&self) -> Ulid { + self.compat_session_id.into() + } +} + impl TryFrom for (CompatSession, Option) { type Error = DatabaseInconsistencyError; diff --git a/crates/storage-pg/src/compat/sso_login.rs b/crates/storage-pg/src/compat/sso_login.rs index eeadff164..43ad4bead 100644 --- a/crates/storage-pg/src/compat/sso_login.rs +++ b/crates/storage-pg/src/compat/sso_login.rs @@ -10,6 +10,7 @@ use mas_data_model::{BrowserSession, Clock, CompatSession, CompatSsoLogin, Compa use mas_storage::{ Page, Pagination, compat::{CompatSsoLoginFilter, CompatSsoLoginRepository}, + pagination::Node, }; use rand::RngCore; use sea_query::{Expr, PostgresQueryBuilder, Query, enum_def}; @@ -54,6 +55,12 @@ struct CompatSsoLoginLookup { compat_session_id: Option, } +impl Node for CompatSsoLoginLookup { + fn cursor(&self) -> Ulid { + self.compat_sso_login_id.into() + } +} + impl TryFrom for CompatSsoLogin { type Error = DatabaseInconsistencyError; diff --git a/crates/storage-pg/src/iden.rs b/crates/storage-pg/src/iden.rs index a861f59c7..4e5a39139 100644 --- a/crates/storage-pg/src/iden.rs +++ b/crates/storage-pg/src/iden.rs @@ -108,6 +108,35 @@ pub enum OAuth2Clients { IsStatic, } +#[derive(sea_query::Iden)] +#[iden = "personal_sessions"] +pub enum PersonalSessions { + Table, + PersonalSessionId, + OwnerUserId, + #[iden = "owner_oauth2_client_id"] + OwnerOAuth2ClientId, + ActorUserId, + HumanName, + ScopeList, + CreatedAt, + RevokedAt, + LastActiveAt, + LastActiveIp, +} + +#[derive(sea_query::Iden)] +#[iden = "personal_access_tokens"] +pub enum PersonalAccessTokens { + Table, + PersonalAccessTokenId, + PersonalSessionId, + // AccessTokenSha256, + CreatedAt, + ExpiresAt, + RevokedAt, +} + #[derive(sea_query::Iden)] #[iden = "upstream_oauth_providers"] pub enum UpstreamOAuthProviders { diff --git a/crates/storage-pg/src/lib.rs b/crates/storage-pg/src/lib.rs index 908058df6..207235667 100644 --- a/crates/storage-pg/src/lib.rs +++ b/crates/storage-pg/src/lib.rs @@ -165,6 +165,7 @@ use sqlx::migrate::Migrator; pub mod app_session; pub mod compat; pub mod oauth2; +pub mod personal; pub mod queue; pub mod upstream_oauth2; pub mod user; diff --git a/crates/storage-pg/src/oauth2/client.rs b/crates/storage-pg/src/oauth2/client.rs index ae7b03ac9..8f7d24224 100644 --- a/crates/storage-pg/src/oauth2/client.rs +++ b/crates/storage-pg/src/oauth2/client.rs @@ -811,6 +811,49 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> { .await?; } + // Delete any personal access tokens & sessions owned + // by the client + { + let span = info_span!( + "db.oauth2_client.delete_by_id.personal_access_tokens", + { DB_QUERY_TEXT } = tracing::field::Empty, + ); + + sqlx::query!( + r#" + DELETE FROM personal_access_tokens + WHERE personal_session_id IN ( + SELECT personal_session_id + FROM personal_sessions + WHERE owner_oauth2_client_id = $1 + ) + "#, + Uuid::from(id), + ) + .record(&span) + .execute(&mut *self.conn) + .instrument(span) + .await?; + } + { + let span = info_span!( + "db.oauth2_client.delete_by_id.personal_sessions", + { DB_QUERY_TEXT } = tracing::field::Empty, + ); + + sqlx::query!( + r#" + DELETE FROM personal_sessions + WHERE owner_oauth2_client_id = $1 + "#, + Uuid::from(id), + ) + .record(&span) + .execute(&mut *self.conn) + .instrument(span) + .await?; + } + // Now delete the client itself let res = sqlx::query!( r#" diff --git a/crates/storage-pg/src/oauth2/mod.rs b/crates/storage-pg/src/oauth2/mod.rs index 6b1c81d46..bf741b5f2 100644 --- a/crates/storage-pg/src/oauth2/mod.rs +++ b/crates/storage-pg/src/oauth2/mod.rs @@ -511,10 +511,10 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 4); - assert_eq!(list.edges[0], session11); - assert_eq!(list.edges[1], session12); - assert_eq!(list.edges[2], session21); - assert_eq!(list.edges[3], session22); + assert_eq!(list.edges[0].node, session11); + assert_eq!(list.edges[1].node, session12); + assert_eq!(list.edges[2].node, session21); + assert_eq!(list.edges[3].node, session22); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 4); @@ -527,8 +527,8 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 2); - assert_eq!(list.edges[0], session11); - assert_eq!(list.edges[1], session21); + assert_eq!(list.edges[0].node, session11); + assert_eq!(list.edges[1].node, session21); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 2); @@ -541,8 +541,8 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 2); - assert_eq!(list.edges[0], session11); - assert_eq!(list.edges[1], session12); + assert_eq!(list.edges[0].node, session11); + assert_eq!(list.edges[1].node, session12); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 2); @@ -557,7 +557,7 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0], session22); + assert_eq!(list.edges[0].node, session22); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 1); @@ -570,8 +570,8 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 2); - assert_eq!(list.edges[0], session12); - assert_eq!(list.edges[1], session21); + assert_eq!(list.edges[0].node, session12); + assert_eq!(list.edges[1].node, session21); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 2); @@ -584,8 +584,8 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 2); - assert_eq!(list.edges[0], session11); - assert_eq!(list.edges[1], session22); + assert_eq!(list.edges[0].node, session11); + assert_eq!(list.edges[1].node, session22); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 2); @@ -598,7 +598,7 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0], session22); + assert_eq!(list.edges[0].node, session22); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 1); @@ -613,7 +613,7 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0], session22); + assert_eq!(list.edges[0].node, session22); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 1); @@ -626,7 +626,7 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0], session12); + assert_eq!(list.edges[0].node, session12); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 1); @@ -641,7 +641,7 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0], session21); + assert_eq!(list.edges[0].node, session21); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 1); @@ -655,10 +655,10 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 4); - assert_eq!(list.edges[0], session11); - assert_eq!(list.edges[1], session12); - assert_eq!(list.edges[2], session21); - assert_eq!(list.edges[3], session22); + assert_eq!(list.edges[0].node, session11); + assert_eq!(list.edges[1].node, session12); + assert_eq!(list.edges[2].node, session21); + assert_eq!(list.edges[3].node, session22); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 4); // We should get all sessions with the "openid" and "email" scope @@ -671,8 +671,8 @@ mod tests { .unwrap(); assert!(!list.has_next_page); assert_eq!(list.edges.len(), 2); - assert_eq!(list.edges[0], session11); - assert_eq!(list.edges[1], session12); + assert_eq!(list.edges[0].node, session11); + assert_eq!(list.edges[1].node, session12); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 2); // Try combining the scope filter with the user filter @@ -685,7 +685,7 @@ mod tests { .await .unwrap(); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0], session11); + assert_eq!(list.edges[0].node, session11); assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 1); // Finish all sessions of a client in batch diff --git a/crates/storage-pg/src/oauth2/session.rs b/crates/storage-pg/src/oauth2/session.rs index b859de164..9550757c6 100644 --- a/crates/storage-pg/src/oauth2/session.rs +++ b/crates/storage-pg/src/oauth2/session.rs @@ -12,6 +12,7 @@ use mas_data_model::{BrowserSession, Client, Clock, Session, SessionState, User} use mas_storage::{ Page, Pagination, oauth2::{OAuth2SessionFilter, OAuth2SessionRepository}, + pagination::Node, }; use oauth2_types::scope::{Scope, ScopeToken}; use rand::RngCore; @@ -61,6 +62,12 @@ struct OAuthSessionLookup { human_name: Option, } +impl Node for OAuthSessionLookup { + fn cursor(&self) -> Ulid { + self.oauth2_session_id.into() + } +} + impl TryFrom for Session { type Error = DatabaseInconsistencyError; diff --git a/crates/storage-pg/src/personal/access_token.rs b/crates/storage-pg/src/personal/access_token.rs new file mode 100644 index 000000000..db8164fe9 --- /dev/null +++ b/crates/storage-pg/src/personal/access_token.rs @@ -0,0 +1,253 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use mas_data_model::{ + Clock, + personal::{PersonalAccessToken, session::PersonalSession}, +}; +use mas_storage::personal::PersonalAccessTokenRepository; +use rand::RngCore; +use sha2::{Digest, Sha256}; +use sqlx::PgConnection; +use ulid::Ulid; +use uuid::Uuid; + +use crate::{DatabaseError, tracing::ExecuteExt as _}; + +/// An implementation of [`PersonalAccessTokenRepository`] for a PostgreSQL +/// connection +pub struct PgPersonalAccessTokenRepository<'c> { + conn: &'c mut PgConnection, +} + +impl<'c> PgPersonalAccessTokenRepository<'c> { + /// Create a new [`PgPersonalAccessTokenRepository`] from an active + /// PostgreSQL connection + pub fn new(conn: &'c mut PgConnection) -> Self { + Self { conn } + } +} + +struct PersonalAccessTokenLookup { + personal_access_token_id: Uuid, + personal_session_id: Uuid, + created_at: DateTime, + expires_at: Option>, + revoked_at: Option>, +} + +impl From for PersonalAccessToken { + fn from(value: PersonalAccessTokenLookup) -> Self { + Self { + id: Ulid::from(value.personal_access_token_id), + session_id: Ulid::from(value.personal_session_id), + created_at: value.created_at, + expires_at: value.expires_at, + revoked_at: value.revoked_at, + } + } +} + +#[async_trait] +impl PersonalAccessTokenRepository for PgPersonalAccessTokenRepository<'_> { + type Error = DatabaseError; + + #[tracing::instrument( + name = "db.personal_access_token.lookup", + skip_all, + fields( + db.query.text, + personal_access_token.id = %id, + ), + err, + )] + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error> { + let res = sqlx::query_as!( + PersonalAccessTokenLookup, + r#" + SELECT personal_access_token_id + , personal_session_id + , created_at + , expires_at + , revoked_at + + FROM personal_access_tokens + + WHERE personal_access_token_id = $1 + "#, + Uuid::from(id), + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + let Some(res) = res else { return Ok(None) }; + + Ok(Some(res.into())) + } + + #[tracing::instrument( + name = "db.personal_access_token.find_by_token", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn find_by_token( + &mut self, + access_token: &str, + ) -> Result, Self::Error> { + let token_sha256 = Sha256::digest(access_token.as_bytes()).to_vec(); + + let res = sqlx::query_as!( + PersonalAccessTokenLookup, + r#" + SELECT personal_access_token_id + , personal_session_id + , created_at + , expires_at + , revoked_at + + FROM personal_access_tokens + + WHERE access_token_sha256 = $1 + "#, + &token_sha256, + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + let Some(res) = res else { return Ok(None) }; + + Ok(Some(res.into())) + } + + #[tracing::instrument( + name = "db.personal_access_token.find_active_for_session", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn find_active_for_session( + &mut self, + session: &PersonalSession, + ) -> Result, Self::Error> { + let res: Option = sqlx::query_as!( + PersonalAccessTokenLookup, + r#" + SELECT personal_access_token_id + , personal_session_id + , created_at + , expires_at + , revoked_at + + FROM personal_access_tokens + + WHERE personal_session_id = $1 + AND revoked_at IS NULL + "#, + Uuid::from(session.id), + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + let Some(res) = res else { return Ok(None) }; + + Ok(Some(res.into())) + } + + #[tracing::instrument( + name = "db.personal_access_token.add", + skip_all, + fields( + db.query.text, + personal_access_token.id, + %session.id, + ), + err, + )] + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + session: &PersonalSession, + access_token: &str, + expires_after: Option, + ) -> Result { + let created_at = clock.now(); + let id = Ulid::from_datetime_with_source(created_at.into(), rng); + tracing::Span::current().record("personal_access_token.id", tracing::field::display(id)); + + let token_sha256 = Sha256::digest(access_token.as_bytes()).to_vec(); + + let expires_at = expires_after.map(|expires_after| created_at + expires_after); + + sqlx::query!( + r#" + INSERT INTO personal_access_tokens + (personal_access_token_id, personal_session_id, access_token_sha256, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5) + "#, + Uuid::from(id), + Uuid::from(session.id), + &token_sha256, + created_at, + expires_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(PersonalAccessToken { + id, + session_id: session.id, + created_at, + expires_at, + revoked_at: None, + }) + } + + #[tracing::instrument( + name = "db.personal_access_token.revoke", + skip_all, + fields( + db.query.text, + %access_token.id, + personal_session.id = %access_token.session_id, + ), + err, + )] + async fn revoke( + &mut self, + clock: &dyn Clock, + mut access_token: PersonalAccessToken, + ) -> Result { + let revoked_at = clock.now(); + let res = sqlx::query!( + r#" + UPDATE personal_access_tokens + SET revoked_at = $2 + WHERE personal_access_token_id = $1 + "#, + Uuid::from(access_token.id), + revoked_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + access_token.revoked_at = Some(revoked_at); + Ok(access_token) + } +} diff --git a/crates/storage-pg/src/personal/mod.rs b/crates/storage-pg/src/personal/mod.rs new file mode 100644 index 000000000..f540a6be3 --- /dev/null +++ b/crates/storage-pg/src/personal/mod.rs @@ -0,0 +1,422 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +//! A module containing the PostgreSQL implementations of the +//! Personal Access Token / Personal Session repositories + +mod access_token; +mod session; + +pub use access_token::PgPersonalAccessTokenRepository; +pub use session::PgPersonalSessionRepository; + +#[cfg(test)] +mod tests { + use chrono::Duration; + use mas_data_model::{ + Clock, Device, clock::MockClock, personal::session::PersonalSessionOwner, + }; + use mas_storage::{ + Pagination, RepositoryAccess, + personal::{ + PersonalAccessTokenRepository, PersonalSessionFilter, PersonalSessionRepository, + }, + user::UserRepository, + }; + use oauth2_types::scope::{OPENID, PROFILE, Scope}; + use rand::SeedableRng; + use rand_chacha::ChaChaRng; + use sqlx::PgPool; + + use crate::PgRepository; + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_session_repository(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + let mut repo = PgRepository::from_pool(&pool).await.unwrap(); + + // Create a user + let admin_user = repo + .user() + .add(&mut rng, &clock, "john".to_owned()) + .await + .unwrap(); + let bot_user = repo + .user() + .add(&mut rng, &clock, "marvin".to_owned()) + .await + .unwrap(); + + let all = PersonalSessionFilter::new().for_actor_user(&bot_user); + let active = all.active_only(); + let finished = all.finished_only(); + let pagination = Pagination::first(10); + + assert_eq!(repo.personal_session().count(all).await.unwrap(), 0); + assert_eq!(repo.personal_session().count(active).await.unwrap(), 0); + assert_eq!(repo.personal_session().count(finished).await.unwrap(), 0); + + // We start off with no sessions + let full_list = repo.personal_session().list(all, pagination).await.unwrap(); + assert!(full_list.edges.is_empty()); + let active_list = repo + .personal_session() + .list(active, pagination) + .await + .unwrap(); + assert!(active_list.edges.is_empty()); + let finished_list = repo + .personal_session() + .list(finished, pagination) + .await + .unwrap(); + assert!(finished_list.edges.is_empty()); + + // Start a personal session for that user + let device = Device::generate(&mut rng); + let scope: Scope = [OPENID, PROFILE] + .into_iter() + .chain(device.to_scope_token().unwrap()) + .collect(); + let session = repo + .personal_session() + .add( + &mut rng, + &clock, + (&admin_user).into(), + &bot_user, + "Test Personal Session".to_owned(), + scope.clone(), + ) + .await + .unwrap(); + assert_eq!(session.owner, PersonalSessionOwner::User(admin_user.id)); + assert_eq!(session.actor_user_id, bot_user.id); + assert!(session.is_valid()); + assert!(!session.is_revoked()); + assert_eq!(session.scope, scope); + + assert_eq!(repo.personal_session().count(all).await.unwrap(), 1); + assert_eq!(repo.personal_session().count(active).await.unwrap(), 1); + assert_eq!(repo.personal_session().count(finished).await.unwrap(), 0); + + let full_list = repo.personal_session().list(all, pagination).await.unwrap(); + assert_eq!(full_list.edges.len(), 1); + assert_eq!(full_list.edges[0].node.0.id, session.id); + assert!(full_list.edges[0].node.0.is_valid()); + let active_list = repo + .personal_session() + .list(active, pagination) + .await + .unwrap(); + assert_eq!(active_list.edges.len(), 1); + assert_eq!(active_list.edges[0].node.0.id, session.id); + assert!(active_list.edges[0].node.0.is_valid()); + let finished_list = repo + .personal_session() + .list(finished, pagination) + .await + .unwrap(); + assert!(finished_list.edges.is_empty()); + + // Lookup the session and check it didn't change + let session_lookup = repo + .personal_session() + .lookup(session.id) + .await + .unwrap() + .expect("personal session not found"); + assert_eq!(session_lookup.id, session.id); + assert_eq!( + session_lookup.owner, + PersonalSessionOwner::User(admin_user.id) + ); + assert_eq!(session_lookup.actor_user_id, bot_user.id); + assert_eq!(session_lookup.scope, scope); + assert!(session_lookup.is_valid()); + assert!(!session_lookup.is_revoked()); + + // Revoke the session + let session = repo + .personal_session() + .revoke(&clock, session) + .await + .unwrap(); + assert!(!session.is_valid()); + assert!(session.is_revoked()); + + assert_eq!(repo.personal_session().count(all).await.unwrap(), 1); + assert_eq!(repo.personal_session().count(active).await.unwrap(), 0); + assert_eq!(repo.personal_session().count(finished).await.unwrap(), 1); + + let full_list = repo.personal_session().list(all, pagination).await.unwrap(); + assert_eq!(full_list.edges.len(), 1); + assert_eq!(full_list.edges[0].node.0.id, session.id); + let active_list = repo + .personal_session() + .list(active, pagination) + .await + .unwrap(); + assert!(active_list.edges.is_empty()); + let finished_list = repo + .personal_session() + .list(finished, pagination) + .await + .unwrap(); + assert_eq!(finished_list.edges.len(), 1); + assert_eq!(finished_list.edges[0].node.0.id, session.id); + assert!(finished_list.edges[0].node.0.is_revoked()); + + // Reload the session and check again + let session_lookup = repo + .personal_session() + .lookup(session.id) + .await + .unwrap() + .expect("personal session not found"); + assert!(!session_lookup.is_valid()); + assert!(session_lookup.is_revoked()); + } + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_session_revoke_bulk(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + let mut repo = PgRepository::from_pool(&pool).await.unwrap(); + + let alice_user = repo + .user() + .add(&mut rng, &clock, "alice".to_owned()) + .await + .unwrap(); + let bob_user = repo + .user() + .add(&mut rng, &clock, "bob".to_owned()) + .await + .unwrap(); + + let session1 = repo + .personal_session() + .add( + &mut rng, + &clock, + (&alice_user).into(), + &bob_user, + "Test Personal Session".to_owned(), + "openid".parse().unwrap(), + ) + .await + .unwrap(); + repo.personal_access_token() + .add( + &mut rng, + &clock, + &session1, + "mpt_hiss", + Some(Duration::days(42)), + ) + .await + .unwrap(); + + let session2 = repo + .personal_session() + .add( + &mut rng, + &clock, + (&bob_user).into(), + &bob_user, + "Test Personal Session".to_owned(), + "openid".parse().unwrap(), + ) + .await + .unwrap(); + repo.personal_access_token() + .add( + &mut rng, &clock, &session2, "mpt_meow", // No expiry + None, + ) + .await + .unwrap(); + + // Just one session without a token expiry time + assert_eq!( + repo.personal_session() + .revoke_bulk( + &clock, + PersonalSessionFilter::new() + .active_only() + .with_expires(false) + ) + .await + .unwrap(), + 1 + ); + + // Just one session with a token expiry time + assert_eq!( + repo.personal_session() + .revoke_bulk( + &clock, + PersonalSessionFilter::new() + .active_only() + .with_expires(true) + ) + .await + .unwrap(), + 1 + ); + + // No active sessions left + assert_eq!( + repo.personal_session() + .revoke_bulk(&clock, PersonalSessionFilter::new().active_only()) + .await + .unwrap(), + 0 + ); + } + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_access_token_repository(pool: PgPool) { + const FIRST_TOKEN: &str = "first_access_token"; + const SECOND_TOKEN: &str = "second_access_token"; + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + // Create a user + let admin_user = repo + .user() + .add(&mut rng, &clock, "john".to_owned()) + .await + .unwrap(); + let bot_user = repo + .user() + .add(&mut rng, &clock, "marvin".to_owned()) + .await + .unwrap(); + + // Start a personal session for that user + let device = Device::generate(&mut rng); + let scope: Scope = [OPENID, PROFILE] + .into_iter() + .chain(device.to_scope_token().unwrap()) + .collect(); + let session = repo + .personal_session() + .add( + &mut rng, + &clock, + (&admin_user).into(), + &bot_user, + "Test Personal Session".to_owned(), + scope, + ) + .await + .unwrap(); + + // Add an access token to that session + let token = repo + .personal_access_token() + .add( + &mut rng, + &clock, + &session, + FIRST_TOKEN, + Some(Duration::try_minutes(1).unwrap()), + ) + .await + .unwrap(); + assert_eq!(token.session_id, session.id); + + // Commit the txn and grab a new transaction, to test a conflict + repo.save().await.unwrap(); + + { + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + // Adding the same token a second time should conflict + assert!( + repo.personal_access_token() + .add( + &mut rng, + &clock, + &session, + FIRST_TOKEN, + Some(Duration::try_minutes(1).unwrap()), + ) + .await + .is_err() + ); + repo.cancel().await.unwrap(); + } + + // Grab a new repo + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + // Looking up via ID works + let token_lookup = repo + .personal_access_token() + .lookup(token.id) + .await + .unwrap() + .expect("personal access token not found"); + assert_eq!(token.id, token_lookup.id); + assert_eq!(token_lookup.session_id, session.id); + + // Looking up via the token value works + let token_lookup = repo + .personal_access_token() + .find_by_token(FIRST_TOKEN) + .await + .unwrap() + .expect("personal access token not found"); + assert_eq!(token.id, token_lookup.id); + assert_eq!(token_lookup.session_id, session.id); + + // Token is currently valid + assert!(token.is_valid(clock.now())); + + clock.advance(Duration::try_minutes(1).unwrap()); + // Token should have expired + assert!(!token.is_valid(clock.now())); + + // Add a second access token, this time without expiration + let _token = repo + .personal_access_token() + .revoke(&clock, token) + .await + .unwrap(); + let token = repo + .personal_access_token() + .add(&mut rng, &clock, &session, SECOND_TOKEN, None) + .await + .unwrap(); + assert_eq!(token.session_id, session.id); + + // Token is currently valid + assert!(token.is_valid(clock.now())); + + // Revoke it + let _token = repo + .personal_access_token() + .revoke(&clock, token) + .await + .unwrap(); + + // Reload it + let token = repo + .personal_access_token() + .find_by_token(SECOND_TOKEN) + .await + .unwrap() + .expect("personal access token not found"); + + // Token is not valid anymore + assert!(!token.is_valid(clock.now())); + + repo.save().await.unwrap(); + } +} diff --git a/crates/storage-pg/src/personal/session.rs b/crates/storage-pg/src/personal/session.rs new file mode 100644 index 000000000..b4c330ecb --- /dev/null +++ b/crates/storage-pg/src/personal/session.rs @@ -0,0 +1,702 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use std::net::IpAddr; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use mas_data_model::{ + Clock, User, + personal::{ + PersonalAccessToken, + session::{PersonalSession, PersonalSessionOwner, SessionState}, + }, +}; +use mas_storage::{ + Page, Pagination, + pagination::Node, + personal::{PersonalSessionFilter, PersonalSessionRepository, PersonalSessionState}, +}; +use oauth2_types::scope::Scope; +use opentelemetry_semantic_conventions::trace::DB_QUERY_TEXT; +use rand::RngCore; +use sea_query::{ + Cond, Condition, Expr, PgFunc, PostgresQueryBuilder, Query, SimpleExpr, enum_def, + extension::postgres::PgExpr as _, +}; +use sea_query_binder::SqlxBinder as _; +use sqlx::PgConnection; +use tracing::{Instrument as _, info_span}; +use ulid::Ulid; +use uuid::Uuid; + +use crate::{ + DatabaseError, + errors::DatabaseInconsistencyError, + filter::{Filter, StatementExt as _}, + iden::{PersonalAccessTokens, PersonalSessions}, + pagination::QueryBuilderExt as _, + tracing::ExecuteExt as _, +}; + +/// An implementation of [`PersonalSessionRepository`] for a PostgreSQL +/// connection +pub struct PgPersonalSessionRepository<'c> { + conn: &'c mut PgConnection, +} + +impl<'c> PgPersonalSessionRepository<'c> { + /// Create a new [`PgPersonalSessionRepository`] from an active PostgreSQL + /// connection + pub fn new(conn: &'c mut PgConnection) -> Self { + Self { conn } + } +} + +#[derive(sqlx::FromRow)] +#[enum_def] +struct PersonalSessionLookup { + personal_session_id: Uuid, + owner_user_id: Option, + owner_oauth2_client_id: Option, + actor_user_id: Uuid, + human_name: String, + scope_list: Vec, + created_at: DateTime, + revoked_at: Option>, + last_active_at: Option>, + last_active_ip: Option, +} + +impl Node for PersonalSessionLookup { + fn cursor(&self) -> Ulid { + self.personal_session_id.into() + } +} + +impl TryFrom for PersonalSession { + type Error = DatabaseInconsistencyError; + + fn try_from(value: PersonalSessionLookup) -> Result { + let id = Ulid::from(value.personal_session_id); + let scope: Result = value.scope_list.iter().map(|s| s.parse()).collect(); + let scope = scope.map_err(|e| { + DatabaseInconsistencyError::on("personal_sessions") + .column("scope") + .row(id) + .source(e) + })?; + + let state = match value.revoked_at { + None => SessionState::Valid, + Some(revoked_at) => SessionState::Revoked { revoked_at }, + }; + + let owner = match (value.owner_user_id, value.owner_oauth2_client_id) { + (Some(owner_user_id), None) => PersonalSessionOwner::User(Ulid::from(owner_user_id)), + (None, Some(owner_oauth2_client_id)) => { + PersonalSessionOwner::OAuth2Client(Ulid::from(owner_oauth2_client_id)) + } + _ => { + // should be impossible (CHECK constraint in Postgres prevents it) + return Err(DatabaseInconsistencyError::on("personal_sessions") + .column("owner_user_id, owner_oauth2_client_id") + .row(id)); + } + }; + + Ok(PersonalSession { + id, + state, + owner, + actor_user_id: Ulid::from(value.actor_user_id), + human_name: value.human_name, + scope, + created_at: value.created_at, + last_active_at: value.last_active_at, + last_active_ip: value.last_active_ip, + }) + } +} + +#[derive(sqlx::FromRow)] +#[enum_def] +struct PersonalSessionAndAccessTokenLookup { + personal_session_id: Uuid, + owner_user_id: Option, + owner_oauth2_client_id: Option, + actor_user_id: Uuid, + human_name: String, + scope_list: Vec, + created_at: DateTime, + revoked_at: Option>, + last_active_at: Option>, + last_active_ip: Option, + + // tokens + personal_access_token_id: Option, + token_created_at: Option>, + token_expires_at: Option>, +} + +impl Node for PersonalSessionAndAccessTokenLookup { + fn cursor(&self) -> Ulid { + self.personal_session_id.into() + } +} + +impl TryFrom + for (PersonalSession, Option) +{ + type Error = DatabaseInconsistencyError; + + fn try_from(value: PersonalSessionAndAccessTokenLookup) -> Result { + let session = PersonalSession::try_from(PersonalSessionLookup { + personal_session_id: value.personal_session_id, + owner_user_id: value.owner_user_id, + owner_oauth2_client_id: value.owner_oauth2_client_id, + actor_user_id: value.actor_user_id, + human_name: value.human_name, + scope_list: value.scope_list, + created_at: value.created_at, + revoked_at: value.revoked_at, + last_active_at: value.last_active_at, + last_active_ip: value.last_active_ip, + })?; + + let token_opt = if let Some(id) = value.personal_access_token_id { + let id = Ulid::from(id); + Some(PersonalAccessToken { + id, + session_id: session.id, + // should not be possible + created_at: value.token_created_at.ok_or( + DatabaseInconsistencyError::on("personal_sessions") + .column("created_at") + .row(id), + )?, + expires_at: value.token_expires_at, + revoked_at: None, + }) + } else { + None + }; + + Ok((session, token_opt)) + } +} + +#[async_trait] +impl PersonalSessionRepository for PgPersonalSessionRepository<'_> { + type Error = DatabaseError; + + #[tracing::instrument( + name = "db.personal_session.lookup", + skip_all, + fields( + db.query.text, + session.id = %id, + ), + err, + )] + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error> { + let res = sqlx::query_as!( + PersonalSessionLookup, + r#" + SELECT personal_session_id + , owner_user_id + , owner_oauth2_client_id + , actor_user_id + , scope_list + , created_at + , revoked_at + , human_name + , last_active_at + , last_active_ip as "last_active_ip: IpAddr" + FROM personal_sessions + + WHERE personal_session_id = $1 + "#, + Uuid::from(id), + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + let Some(session) = res else { return Ok(None) }; + + Ok(Some(session.try_into()?)) + } + + #[tracing::instrument( + name = "db.personal_session.add", + skip_all, + fields( + db.query.text, + session.id, + session.scope = %scope, + ), + err, + )] + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + owner: PersonalSessionOwner, + actor_user: &User, + human_name: String, + scope: Scope, + ) -> Result { + let created_at = clock.now(); + let id = Ulid::from_datetime_with_source(created_at.into(), rng); + tracing::Span::current().record("session.id", tracing::field::display(id)); + + let scope_list: Vec = scope.iter().map(|s| s.as_str().to_owned()).collect(); + + let (owner_user_id, owner_oauth2_client_id) = match owner { + PersonalSessionOwner::User(ulid) => (Some(Uuid::from(ulid)), None), + PersonalSessionOwner::OAuth2Client(ulid) => (None, Some(Uuid::from(ulid))), + }; + + sqlx::query!( + r#" + INSERT INTO personal_sessions + ( personal_session_id + , owner_user_id + , owner_oauth2_client_id + , actor_user_id + , human_name + , scope_list + , created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + "#, + Uuid::from(id), + owner_user_id, + owner_oauth2_client_id, + Uuid::from(actor_user.id), + &human_name, + &scope_list, + created_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(PersonalSession { + id, + state: SessionState::Valid, + owner, + actor_user_id: actor_user.id, + human_name, + scope, + created_at, + last_active_at: None, + last_active_ip: None, + }) + } + + #[tracing::instrument( + name = "db.personal_session.revoke", + skip_all, + fields( + db.query.text, + %session.id, + %session.scope, + ), + err, + )] + async fn revoke( + &mut self, + clock: &dyn Clock, + session: PersonalSession, + ) -> Result { + let revoked_at = clock.now(); + + { + // Revoke dependent PATs + let span = info_span!( + "db.personal_session.revoke.tokens", + { DB_QUERY_TEXT } = tracing::field::Empty, + ); + + sqlx::query!( + r#" + UPDATE personal_access_tokens + SET revoked_at = $2 + WHERE personal_session_id = $1 AND revoked_at IS NULL + "#, + Uuid::from(session.id), + revoked_at, + ) + .record(&span) + .execute(&mut *self.conn) + .instrument(span) + .await?; + } + + let res = sqlx::query!( + r#" + UPDATE personal_sessions + SET revoked_at = $2 + WHERE personal_session_id = $1 + "#, + Uuid::from(session.id), + revoked_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + session + .finish(revoked_at) + .map_err(DatabaseError::to_invalid_operation) + } + + #[tracing::instrument( + name = "db.personal_session.revoke_bulk", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn revoke_bulk( + &mut self, + clock: &dyn Clock, + filter: PersonalSessionFilter<'_>, + ) -> Result { + let revoked_at = clock.now(); + + let (sql, arguments) = Query::update() + .table(PersonalSessions::Table) + .value(PersonalSessions::RevokedAt, revoked_at) + .and_where( + Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId)) + // Because filters apply to both the session and access token tables, + // Use a subquery to make it possible to use a JOIN + // onto the personal access token table. + .in_subquery( + Query::select() + .expr(Expr::col(( + PersonalSessions::Table, + PersonalSessions::PersonalSessionId, + ))) + .from(PersonalSessions::Table) + .left_join( + PersonalAccessTokens::Table, + Cond::all() + // Match session ID + .add( + Expr::col(( + PersonalSessions::Table, + PersonalSessions::PersonalSessionId, + )) + .eq(Expr::col(( + PersonalAccessTokens::Table, + PersonalAccessTokens::PersonalSessionId, + ))), + ) + // Only choose the active access token for each session + .add( + Expr::col(( + PersonalAccessTokens::Table, + PersonalAccessTokens::RevokedAt, + )) + .is_null(), + ), + ) + .apply_filter(filter) + .take(), + ), + ) + .build_sqlx(PostgresQueryBuilder); + + let res = sqlx::query_with(&sql, arguments) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(res.rows_affected().try_into().unwrap_or(usize::MAX)) + } + + #[tracing::instrument( + name = "db.personal_session.list", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn list( + &mut self, + filter: PersonalSessionFilter<'_>, + pagination: Pagination, + ) -> Result)>, Self::Error> { + let (sql, arguments) = Query::select() + .expr_as( + Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId)), + PersonalSessionAndAccessTokenLookupIden::PersonalSessionId, + ) + .expr_as( + Expr::col((PersonalSessions::Table, PersonalSessions::OwnerUserId)), + PersonalSessionAndAccessTokenLookupIden::OwnerUserId, + ) + .expr_as( + Expr::col(( + PersonalSessions::Table, + PersonalSessions::OwnerOAuth2ClientId, + )), + PersonalSessionAndAccessTokenLookupIden::OwnerOauth2ClientId, + ) + .expr_as( + Expr::col((PersonalSessions::Table, PersonalSessions::ActorUserId)), + PersonalSessionAndAccessTokenLookupIden::ActorUserId, + ) + .expr_as( + Expr::col((PersonalSessions::Table, PersonalSessions::HumanName)), + PersonalSessionAndAccessTokenLookupIden::HumanName, + ) + .expr_as( + Expr::col((PersonalSessions::Table, PersonalSessions::ScopeList)), + PersonalSessionAndAccessTokenLookupIden::ScopeList, + ) + .expr_as( + Expr::col((PersonalSessions::Table, PersonalSessions::CreatedAt)), + PersonalSessionAndAccessTokenLookupIden::CreatedAt, + ) + .expr_as( + Expr::col((PersonalSessions::Table, PersonalSessions::RevokedAt)), + PersonalSessionAndAccessTokenLookupIden::RevokedAt, + ) + .expr_as( + Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveAt)), + PersonalSessionAndAccessTokenLookupIden::LastActiveAt, + ) + .expr_as( + Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveIp)), + PersonalSessionAndAccessTokenLookupIden::LastActiveIp, + ) + .expr_as( + Expr::col(( + PersonalAccessTokens::Table, + PersonalAccessTokens::PersonalAccessTokenId, + )), + PersonalSessionAndAccessTokenLookupIden::PersonalAccessTokenId, + ) + .expr_as( + Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::CreatedAt)), + PersonalSessionAndAccessTokenLookupIden::TokenCreatedAt, + ) + .expr_as( + Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::ExpiresAt)), + PersonalSessionAndAccessTokenLookupIden::TokenExpiresAt, + ) + .from(PersonalSessions::Table) + .left_join( + PersonalAccessTokens::Table, + Cond::all() + // Match session ID + .add( + Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId)) + .eq(Expr::col(( + PersonalAccessTokens::Table, + PersonalAccessTokens::PersonalSessionId, + ))), + ) + // Only choose the active access token for each session + .add( + Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::RevokedAt)) + .is_null(), + ), + ) + .apply_filter(filter) + .generate_pagination( + (PersonalSessions::Table, PersonalSessions::PersonalSessionId), + pagination, + ) + .build_sqlx(PostgresQueryBuilder); + + let edges: Vec = sqlx::query_as_with(&sql, arguments) + .traced() + .fetch_all(&mut *self.conn) + .await?; + + let page = pagination.process(edges).try_map(TryFrom::try_from)?; + + Ok(page) + } + + #[tracing::instrument( + name = "db.personal_session.count", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn count(&mut self, filter: PersonalSessionFilter<'_>) -> Result { + let (sql, arguments) = Query::select() + .expr(Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId)).count()) + .from(PersonalSessions::Table) + .left_join( + PersonalAccessTokens::Table, + Cond::all() + // Match session ID + .add( + Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId)) + .eq(Expr::col(( + PersonalAccessTokens::Table, + PersonalAccessTokens::PersonalSessionId, + ))), + ) + // Only choose the active access token for each session + .add( + Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::RevokedAt)) + .is_null(), + ), + ) + .apply_filter(filter) + .build_sqlx(PostgresQueryBuilder); + + let count: i64 = sqlx::query_scalar_with(&sql, arguments) + .traced() + .fetch_one(&mut *self.conn) + .await?; + + count + .try_into() + .map_err(DatabaseError::to_invalid_operation) + } + + #[tracing::instrument( + name = "db.personal_session.record_batch_activity", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn record_batch_activity( + &mut self, + mut activities: Vec<(Ulid, DateTime, Option)>, + ) -> Result<(), Self::Error> { + // Sort the activity by ID, so that when batching the updates, Postgres + // locks the rows in a stable order, preventing deadlocks + activities.sort_unstable(); + let mut ids = Vec::with_capacity(activities.len()); + let mut last_activities = Vec::with_capacity(activities.len()); + let mut ips = Vec::with_capacity(activities.len()); + + for (id, last_activity, ip) in activities { + ids.push(Uuid::from(id)); + last_activities.push(last_activity); + ips.push(ip); + } + + let res = sqlx::query!( + r#" + UPDATE personal_sessions + SET last_active_at = GREATEST(t.last_active_at, personal_sessions.last_active_at) + , last_active_ip = COALESCE(t.last_active_ip, personal_sessions.last_active_ip) + FROM ( + SELECT * + FROM UNNEST($1::uuid[], $2::timestamptz[], $3::inet[]) + AS t(personal_session_id, last_active_at, last_active_ip) + ) AS t + WHERE personal_sessions.personal_session_id = t.personal_session_id + "#, + &ids, + &last_activities, + &ips as &[Option], + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, ids.len().try_into().unwrap_or(u64::MAX))?; + + Ok(()) + } +} + +impl Filter for PersonalSessionFilter<'_> { + fn generate_condition(&self, _has_joins: bool) -> impl sea_query::IntoCondition { + sea_query::Condition::all() + .add_option(self.owner_user().map(|user| { + Expr::col((PersonalSessions::Table, PersonalSessions::OwnerUserId)) + .eq(Uuid::from(user.id)) + })) + .add_option(self.owner_oauth2_client().map(|client| { + Expr::col(( + PersonalSessions::Table, + PersonalSessions::OwnerOAuth2ClientId, + )) + .eq(Uuid::from(client.id)) + })) + .add_option(self.actor_user().map(|user| { + Expr::col((PersonalSessions::Table, PersonalSessions::ActorUserId)) + .eq(Uuid::from(user.id)) + })) + .add_option(self.device().map(|device| -> SimpleExpr { + if let Ok([stable_scope_token, unstable_scope_token]) = device.to_scope_token() { + Condition::any() + .add( + Expr::val(stable_scope_token.to_string()).eq(PgFunc::any(Expr::col(( + PersonalSessions::Table, + PersonalSessions::ScopeList, + )))), + ) + .add(Expr::val(unstable_scope_token.to_string()).eq(PgFunc::any( + Expr::col((PersonalSessions::Table, PersonalSessions::ScopeList)), + ))) + .into() + } else { + // If the device ID can't be encoded as a scope token, match no rows + Expr::val(false).into() + } + })) + .add_option(self.state().map(|state| match state { + PersonalSessionState::Active => { + Expr::col((PersonalSessions::Table, PersonalSessions::RevokedAt)).is_null() + } + PersonalSessionState::Revoked => { + Expr::col((PersonalSessions::Table, PersonalSessions::RevokedAt)).is_not_null() + } + })) + .add_option(self.scope().map(|scope| { + let scope: Vec = scope.iter().map(|s| s.as_str().to_owned()).collect(); + Expr::col((PersonalSessions::Table, PersonalSessions::ScopeList)).contains(scope) + })) + .add_option(self.last_active_before().map(|last_active_before| { + Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveAt)) + .lt(last_active_before) + })) + .add_option(self.last_active_after().map(|last_active_after| { + Expr::col((PersonalSessions::Table, PersonalSessions::LastActiveAt)) + .gt(last_active_after) + })) + .add_option(self.expires_before().map(|expires_before| { + Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::ExpiresAt)) + .lt(expires_before) + })) + .add_option(self.expires_after().map(|expires_after| { + Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::ExpiresAt)) + .gt(expires_after) + })) + .add_option(self.expires().map(|expires| { + let column = + Expr::col((PersonalAccessTokens::Table, PersonalAccessTokens::ExpiresAt)); + + if expires { + column.is_not_null() + } else { + column.is_null() + } + })) + } +} diff --git a/crates/storage-pg/src/repository.rs b/crates/storage-pg/src/repository.rs index 7911cd2b6..210d66a02 100644 --- a/crates/storage-pg/src/repository.rs +++ b/crates/storage-pg/src/repository.rs @@ -20,6 +20,7 @@ use mas_storage::{ OAuth2AccessTokenRepository, OAuth2AuthorizationGrantRepository, OAuth2ClientRepository, OAuth2DeviceCodeGrantRepository, OAuth2RefreshTokenRepository, OAuth2SessionRepository, }, + personal::PersonalSessionRepository, policy_data::PolicyDataRepository, queue::{QueueJobRepository, QueueScheduleRepository, QueueWorkerRepository}, upstream_oauth2::{ @@ -47,6 +48,7 @@ use crate::{ PgOAuth2ClientRepository, PgOAuth2DeviceCodeGrantRepository, PgOAuth2RefreshTokenRepository, PgOAuth2SessionRepository, }, + personal::{PgPersonalAccessTokenRepository, PgPersonalSessionRepository}, policy_data::PgPolicyDataRepository, queue::{ job::PgQueueJobRepository, schedule::PgQueueScheduleRepository, @@ -328,6 +330,19 @@ where Box::new(PgCompatRefreshTokenRepository::new(self.conn.as_mut())) } + fn personal_access_token<'c>( + &'c mut self, + ) -> Box + 'c> + { + Box::new(PgPersonalAccessTokenRepository::new(self.conn.as_mut())) + } + + fn personal_session<'c>( + &'c mut self, + ) -> Box + 'c> { + Box::new(PgPersonalSessionRepository::new(self.conn.as_mut())) + } + fn queue_worker<'c>(&'c mut self) -> Box + 'c> { Box::new(PgQueueWorkerRepository::new(self.conn.as_mut())) } diff --git a/crates/storage-pg/src/upstream_oauth2/link.rs b/crates/storage-pg/src/upstream_oauth2/link.rs index 6f671655d..c43dd8a18 100644 --- a/crates/storage-pg/src/upstream_oauth2/link.rs +++ b/crates/storage-pg/src/upstream_oauth2/link.rs @@ -9,6 +9,7 @@ use chrono::{DateTime, Utc}; use mas_data_model::{Clock, UpstreamOAuthLink, UpstreamOAuthProvider, User}; use mas_storage::{ Page, Pagination, + pagination::Node, upstream_oauth2::{UpstreamOAuthLinkFilter, UpstreamOAuthLinkRepository}, }; use opentelemetry_semantic_conventions::trace::DB_QUERY_TEXT; @@ -53,6 +54,12 @@ struct LinkLookup { created_at: DateTime, } +impl Node for LinkLookup { + fn cursor(&self) -> Ulid { + self.upstream_oauth_link_id.into() + } +} + impl From for UpstreamOAuthLink { fn from(value: LinkLookup) -> Self { UpstreamOAuthLink { diff --git a/crates/storage-pg/src/upstream_oauth2/mod.rs b/crates/storage-pg/src/upstream_oauth2/mod.rs index 6381c9b7f..d98e840b6 100644 --- a/crates/storage-pg/src/upstream_oauth2/mod.rs +++ b/crates/storage-pg/src/upstream_oauth2/mod.rs @@ -206,8 +206,8 @@ mod tests { assert!(!links.has_previous_page); assert!(!links.has_next_page); assert_eq!(links.edges.len(), 1); - assert_eq!(links.edges[0].id, link.id); - assert_eq!(links.edges[0].user_id, Some(user.id)); + assert_eq!(links.edges[0].node.id, link.id); + assert_eq!(links.edges[0].node.user_id, Some(user.id)); assert_eq!(repo.upstream_oauth_link().count(filter).await.unwrap(), 1); @@ -282,7 +282,7 @@ mod tests { .unwrap(); assert_eq!(session_page.edges.len(), 1); - assert_eq!(session_page.edges[0].id, session.id); + assert_eq!(session_page.edges[0].node.id, session.id); assert!(!session_page.has_next_page); assert!(!session_page.has_previous_page); @@ -374,7 +374,7 @@ mod tests { // It returned the first 10 items assert!(page.has_next_page); - let edge_ids: Vec<_> = page.edges.iter().map(|p| p.id).collect(); + let edge_ids: Vec<_> = page.edges.iter().map(|p| p.node.id).collect(); assert_eq!(&edge_ids, &ids[..10]); // Getting the same page with the "enabled only" filter should return the same @@ -396,7 +396,7 @@ mod tests { // It returned the next 10 items assert!(!page.has_next_page); - let edge_ids: Vec<_> = page.edges.iter().map(|p| p.id).collect(); + let edge_ids: Vec<_> = page.edges.iter().map(|p| p.node.id).collect(); assert_eq!(&edge_ids, &ids[10..]); // Lookup the last 10 items @@ -408,7 +408,7 @@ mod tests { // It returned the last 10 items assert!(page.has_previous_page); - let edge_ids: Vec<_> = page.edges.iter().map(|p| p.id).collect(); + let edge_ids: Vec<_> = page.edges.iter().map(|p| p.node.id).collect(); assert_eq!(&edge_ids, &ids[10..]); // Lookup the previous 10 items @@ -420,7 +420,7 @@ mod tests { // It returned the previous 10 items assert!(!page.has_previous_page); - let edge_ids: Vec<_> = page.edges.iter().map(|p| p.id).collect(); + let edge_ids: Vec<_> = page.edges.iter().map(|p| p.node.id).collect(); assert_eq!(&edge_ids, &ids[..10]); // Lookup 10 items between two IDs @@ -432,7 +432,7 @@ mod tests { // It returned the items in between assert!(!page.has_next_page); - let edge_ids: Vec<_> = page.edges.iter().map(|p| p.id).collect(); + let edge_ids: Vec<_> = page.edges.iter().map(|p| p.node.id).collect(); assert_eq!(&edge_ids, &ids[6..8]); // There should not be any disabled providers @@ -560,7 +560,7 @@ mod tests { // It returned the first 10 items assert!(page.has_next_page); - let edge_ids: Vec<_> = page.edges.iter().map(|s| s.id).collect(); + let edge_ids: Vec<_> = page.edges.iter().map(|s| s.node.id).collect(); assert_eq!(&edge_ids, &ids[..10]); // Lookup the next 10 items @@ -572,7 +572,7 @@ mod tests { // It returned the next 10 items assert!(!page.has_next_page); - let edge_ids: Vec<_> = page.edges.iter().map(|s| s.id).collect(); + let edge_ids: Vec<_> = page.edges.iter().map(|s| s.node.id).collect(); assert_eq!(&edge_ids, &ids[10..]); // Lookup the last 10 items @@ -584,7 +584,7 @@ mod tests { // It returned the last 10 items assert!(page.has_previous_page); - let edge_ids: Vec<_> = page.edges.iter().map(|s| s.id).collect(); + let edge_ids: Vec<_> = page.edges.iter().map(|s| s.node.id).collect(); assert_eq!(&edge_ids, &ids[10..]); // Lookup the previous 10 items @@ -596,7 +596,7 @@ mod tests { // It returned the previous 10 items assert!(!page.has_previous_page); - let edge_ids: Vec<_> = page.edges.iter().map(|s| s.id).collect(); + let edge_ids: Vec<_> = page.edges.iter().map(|s| s.node.id).collect(); assert_eq!(&edge_ids, &ids[..10]); // Lookup 5 items between two IDs @@ -608,7 +608,7 @@ mod tests { // It returned the items in between assert!(!page.has_next_page); - let edge_ids: Vec<_> = page.edges.iter().map(|s| s.id).collect(); + let edge_ids: Vec<_> = page.edges.iter().map(|s| s.node.id).collect(); assert_eq!(&edge_ids, &ids[6..11]); // Check the sub/sid filters @@ -638,11 +638,21 @@ mod tests { assert_eq!(page.edges.len(), 4); for edge in page.edges { assert_eq!( - edge.id_token_claims().unwrap().get("sub").unwrap().as_str(), + edge.node + .id_token_claims() + .unwrap() + .get("sub") + .unwrap() + .as_str(), Some("alice") ); assert_eq!( - edge.id_token_claims().unwrap().get("sid").unwrap().as_str(), + edge.node + .id_token_claims() + .unwrap() + .get("sid") + .unwrap() + .as_str(), Some("one") ); } diff --git a/crates/storage-pg/src/upstream_oauth2/provider.rs b/crates/storage-pg/src/upstream_oauth2/provider.rs index 583f8ec0c..caade738d 100644 --- a/crates/storage-pg/src/upstream_oauth2/provider.rs +++ b/crates/storage-pg/src/upstream_oauth2/provider.rs @@ -9,6 +9,7 @@ use chrono::{DateTime, Utc}; use mas_data_model::{Clock, UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports}; use mas_storage::{ Page, Pagination, + pagination::Node, upstream_oauth2::{ UpstreamOAuthProviderFilter, UpstreamOAuthProviderParams, UpstreamOAuthProviderRepository, }, @@ -74,6 +75,12 @@ struct ProviderLookup { on_backchannel_logout: String, } +impl Node for ProviderLookup { + fn cursor(&self) -> Ulid { + self.upstream_oauth_provider_id.into() + } +} + impl TryFrom for UpstreamOAuthProvider { type Error = DatabaseInconsistencyError; diff --git a/crates/storage-pg/src/upstream_oauth2/session.rs b/crates/storage-pg/src/upstream_oauth2/session.rs index 8b37aae2e..b961c4f8c 100644 --- a/crates/storage-pg/src/upstream_oauth2/session.rs +++ b/crates/storage-pg/src/upstream_oauth2/session.rs @@ -12,6 +12,7 @@ use mas_data_model::{ }; use mas_storage::{ Page, Pagination, + pagination::Node, upstream_oauth2::{UpstreamOAuthSessionFilter, UpstreamOAuthSessionRepository}, }; use rand::RngCore; @@ -91,6 +92,12 @@ struct SessionLookup { unlinked_at: Option>, } +impl Node for SessionLookup { + fn cursor(&self) -> Ulid { + self.upstream_oauth_authorization_session_id.into() + } +} + impl TryFrom for UpstreamOAuthAuthorizationSession { type Error = DatabaseInconsistencyError; diff --git a/crates/storage-pg/src/user/email.rs b/crates/storage-pg/src/user/email.rs index 916874ae8..0f998e55f 100644 --- a/crates/storage-pg/src/user/email.rs +++ b/crates/storage-pg/src/user/email.rs @@ -12,6 +12,7 @@ use mas_data_model::{ }; use mas_storage::{ Page, Pagination, + pagination::Node, user::{UserEmailFilter, UserEmailRepository}, }; use rand::RngCore; @@ -51,6 +52,12 @@ struct UserEmailLookup { created_at: DateTime, } +impl Node for UserEmailLookup { + fn cursor(&self) -> Ulid { + self.user_email_id.into() + } +} + impl From for UserEmail { fn from(e: UserEmailLookup) -> UserEmail { UserEmail { diff --git a/crates/storage-pg/src/user/mod.rs b/crates/storage-pg/src/user/mod.rs index 0be594556..2ea882f83 100644 --- a/crates/storage-pg/src/user/mod.rs +++ b/crates/storage-pg/src/user/mod.rs @@ -61,7 +61,9 @@ mod priv_ { #![allow(missing_docs)] use chrono::{DateTime, Utc}; + use mas_storage::pagination::Node; use sea_query::enum_def; + use ulid::Ulid; use uuid::Uuid; #[derive(Debug, Clone, sqlx::FromRow)] @@ -74,6 +76,15 @@ mod priv_ { pub(super) deactivated_at: Option>, pub(super) can_request_admin: bool, pub(super) is_guest: bool, +<<<<<<< HEAD +======= + } + + impl Node for UserLookup { + fn cursor(&self) -> Ulid { + self.user_id.into() + } +>>>>>>> v1.6.0 } } diff --git a/crates/storage-pg/src/user/registration_token.rs b/crates/storage-pg/src/user/registration_token.rs index f64c8136d..5c9231aa1 100644 --- a/crates/storage-pg/src/user/registration_token.rs +++ b/crates/storage-pg/src/user/registration_token.rs @@ -8,6 +8,7 @@ use chrono::{DateTime, Utc}; use mas_data_model::{Clock, UserRegistrationToken}; use mas_storage::{ Page, Pagination, + pagination::Node, user::{UserRegistrationTokenFilter, UserRegistrationTokenRepository}, }; use rand::RngCore; @@ -53,6 +54,12 @@ struct UserRegistrationTokenLookup { revoked_at: Option>, } +impl Node for UserRegistrationTokenLookup { + fn cursor(&self) -> Ulid { + self.user_registration_token_id.into() + } +} + impl Filter for UserRegistrationTokenFilter { fn generate_condition(&self, _has_joins: bool) -> impl sea_query::IntoCondition { sea_query::Condition::all() @@ -230,7 +237,7 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> { filter: UserRegistrationTokenFilter, pagination: Pagination, ) -> Result, Self::Error> { - let (sql, values) = Query::select() + let (sql, arguments) = Query::select() .expr_as( Expr::col(( UserRegistrationTokens::Table, @@ -295,15 +302,14 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> { ) .build_sqlx(PostgresQueryBuilder); - let tokens = sqlx::query_as_with::<_, UserRegistrationTokenLookup, _>(&sql, values) + let edges: Vec = sqlx::query_as_with(&sql, arguments) .traced() .fetch_all(&mut *self.conn) - .await? - .into_iter() - .map(TryInto::try_into) - .collect::, _>>()?; + .await?; - let page = pagination.process(tokens); + let page = pagination + .process(edges) + .try_map(UserRegistrationToken::try_from)?; Ok(page) } @@ -705,7 +711,7 @@ mod tests { .await .unwrap(); - assert!(page.edges.iter().any(|t| t.id == unrevoked_token.id)); + assert!(page.edges.iter().any(|t| t.node.id == unrevoked_token.id)); } #[sqlx::test(migrator = "crate::MIGRATOR")] @@ -867,7 +873,7 @@ mod tests { .await .unwrap(); assert_eq!(page.edges.len(), 1); - assert_eq!(page.edges[0].id, token2.id); + assert_eq!(page.edges[0].node.id, token2.id); // Test unused filter let unused_filter = UserRegistrationTokenFilter::new(clock.now()).with_been_used(false); @@ -886,7 +892,7 @@ mod tests { .await .unwrap(); assert_eq!(page.edges.len(), 1); - assert_eq!(page.edges[0].id, token3.id); + assert_eq!(page.edges[0].node.id, token3.id); let not_expired_filter = UserRegistrationTokenFilter::new(clock.now()).with_expired(false); let page = repo @@ -904,7 +910,7 @@ mod tests { .await .unwrap(); assert_eq!(page.edges.len(), 1); - assert_eq!(page.edges[0].id, token4.id); + assert_eq!(page.edges[0].node.id, token4.id); let not_revoked_filter = UserRegistrationTokenFilter::new(clock.now()).with_revoked(false); let page = repo @@ -941,7 +947,7 @@ mod tests { .await .unwrap(); assert_eq!(page.edges.len(), 1); - assert_eq!(page.edges[0].id, token4.id); + assert_eq!(page.edges[0].node.id, token4.id); // Test pagination let page = repo diff --git a/crates/storage-pg/src/user/session.rs b/crates/storage-pg/src/user/session.rs index 54645b3cb..eec16a162 100644 --- a/crates/storage-pg/src/user/session.rs +++ b/crates/storage-pg/src/user/session.rs @@ -14,6 +14,7 @@ use mas_data_model::{ }; use mas_storage::{ Page, Pagination, + pagination::Node, user::{BrowserSessionFilter, BrowserSessionRepository}, }; use rand::RngCore; @@ -62,6 +63,15 @@ struct SessionLookup { user_deactivated_at: Option>, user_can_request_admin: bool, user_is_guest: bool, +<<<<<<< HEAD +======= +} + +impl Node for SessionLookup { + fn cursor(&self) -> Ulid { + self.user_id.into() + } +>>>>>>> v1.6.0 } impl TryFrom for BrowserSession { diff --git a/crates/storage-pg/src/user/tests.rs b/crates/storage-pg/src/user/tests.rs index 0ee978914..98489d68d 100644 --- a/crates/storage-pg/src/user/tests.rs +++ b/crates/storage-pg/src/user/tests.rs @@ -190,7 +190,7 @@ async fn test_user_repo(pool: PgPool) { // Check the list method let list = repo.user().list(all, Pagination::first(10)).await.unwrap(); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0].id, user.id); + assert_eq!(list.edges[0].node.id, user.id); let list = repo .user() @@ -205,7 +205,7 @@ async fn test_user_repo(pool: PgPool) { .await .unwrap(); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0].id, user.id); + assert_eq!(list.edges[0].node.id, user.id); let list = repo .user() @@ -227,7 +227,7 @@ async fn test_user_repo(pool: PgPool) { .await .unwrap(); assert_eq!(list.edges.len(), 1); - assert_eq!(list.edges[0].id, user.id); + assert_eq!(list.edges[0].node.id, user.id); repo.save().await.unwrap(); } @@ -348,7 +348,7 @@ async fn test_user_email_repo(pool: PgPool) { .unwrap(); assert!(!emails.has_next_page); assert_eq!(emails.edges.len(), 1); - assert_eq!(emails.edges[0], user_email); + assert_eq!(emails.edges[0].node, user_email); // Listing emails from the email address should work let emails = repo @@ -358,7 +358,7 @@ async fn test_user_email_repo(pool: PgPool) { .unwrap(); assert!(!emails.has_next_page); assert_eq!(emails.edges.len(), 1); - assert_eq!(emails.edges[0], user_email); + assert_eq!(emails.edges[0].node, user_email); // Filtering on another email should not return anything let emails = repo @@ -648,7 +648,7 @@ async fn test_user_session(pool: PgPool) { .unwrap(); assert!(!session_list.has_next_page); assert_eq!(session_list.edges.len(), 1); - assert_eq!(session_list.edges[0], session); + assert_eq!(session_list.edges[0].node, session); let session_lookup = repo .browser_session() @@ -809,7 +809,7 @@ async fn test_user_session(pool: PgPool) { .await .unwrap(); assert_eq!(page.edges.len(), 1); - assert_eq!(page.edges[0].id, session.id); + assert_eq!(page.edges[0].node.id, session.id); // Try counting assert_eq!(repo.browser_session().count(filter).await.unwrap(), 1); diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 605dea279..7a19f05ac 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -111,6 +111,7 @@ mod utils; pub mod app_session; pub mod compat; pub mod oauth2; +pub mod personal; pub mod policy_data; pub mod queue; pub mod upstream_oauth2; diff --git a/crates/storage/src/pagination.rs b/crates/storage/src/pagination.rs index 01b8ed197..ad632cb10 100644 --- a/crates/storage/src/pagination.rs +++ b/crates/storage/src/pagination.rs @@ -16,12 +16,12 @@ pub struct InvalidPagination; /// Pagination parameters #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Pagination { +pub struct Pagination { /// The cursor to start from - pub before: Option, + pub before: Option, /// The cursor to end at - pub after: Option, + pub after: Option, /// The maximum number of items to return pub count: usize, @@ -40,16 +40,22 @@ pub enum PaginationDirection { Backward, } -impl Pagination { +/// A node in a page, with a cursor +pub trait Node { + /// The cursor of that particular node + fn cursor(&self) -> C; +} + +impl Pagination { /// Creates a new [`Pagination`] from user-provided parameters. /// /// # Errors /// /// Either `first` or `last` must be provided, else this function will /// return an [`InvalidPagination`] error. - pub const fn try_new( - before: Option, - after: Option, + pub fn try_new( + before: Option, + after: Option, first: Option, last: Option, ) -> Result { @@ -91,49 +97,57 @@ impl Pagination { /// Get items before the given cursor #[must_use] - pub const fn before(mut self, id: Ulid) -> Self { - self.before = Some(id); + pub fn before(mut self, cursor: C) -> Self { + self.before = Some(cursor); self } /// Clear the before cursor #[must_use] - pub const fn clear_before(mut self) -> Self { + pub fn clear_before(mut self) -> Self { self.before = None; self } /// Get items after the given cursor #[must_use] - pub const fn after(mut self, id: Ulid) -> Self { - self.after = Some(id); + pub fn after(mut self, cursor: C) -> Self { + self.after = Some(cursor); self } /// Clear the after cursor #[must_use] - pub const fn clear_after(mut self) -> Self { + pub fn clear_after(mut self) -> Self { self.after = None; self } /// Process a page returned by a paginated query #[must_use] - pub fn process(&self, mut edges: Vec) -> Page { - let is_full = edges.len() == (self.count + 1); + pub fn process>(&self, mut nodes: Vec) -> Page { + let is_full = nodes.len() == (self.count + 1); if is_full { - edges.pop(); + nodes.pop(); } let (has_previous_page, has_next_page) = match self.direction { PaginationDirection::Forward => (false, is_full), PaginationDirection::Backward => { // 6. If the last argument is provided, I reverse the order of the results - edges.reverse(); + nodes.reverse(); (is_full, false) } }; + let edges = nodes + .into_iter() + .map(|node| Edge { + cursor: node.cursor(), + node, + }) + .collect(); + Page { has_next_page, has_previous_page, @@ -142,9 +156,18 @@ impl Pagination { } } +/// An edge in a paginated result +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Edge { + /// The cursor of the edge + pub cursor: C, + /// The node of the edge + pub node: T, +} + /// A page of results returned by a paginated query #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Page { +pub struct Page { /// When paginating forwards, this is true if there are more items after pub has_next_page: bool, @@ -152,21 +175,28 @@ pub struct Page { pub has_previous_page: bool, /// The items in the page - pub edges: Vec, + pub edges: Vec>, } -impl Page { +impl Page { /// Map the items in this page with the given function /// /// # Parameters /// /// * `f`: The function to map the items with #[must_use] - pub fn map(self, f: F) -> Page + pub fn map(self, mut f: F) -> Page where F: FnMut(T) -> T2, { - let edges = self.edges.into_iter().map(f).collect(); + let edges = self + .edges + .into_iter() + .map(|edge| Edge { + cursor: edge.cursor, + node: f(edge.node), + }) + .collect(); Page { has_next_page: self.has_next_page, has_previous_page: self.has_previous_page, @@ -183,11 +213,21 @@ impl Page { /// # Errors /// /// Returns the first error encountered while mapping the items - pub fn try_map(self, f: F) -> Result, E> + pub fn try_map(self, mut f: F) -> Result, E> where F: FnMut(T) -> Result, { - let edges: Result, E> = self.edges.into_iter().map(f).collect(); + let edges: Result>, E> = self + .edges + .into_iter() + .map(|edge| { + Ok(Edge { + cursor: edge.cursor, + node: f(edge.node)?, + }) + }) + .collect(); + Ok(Page { has_next_page: self.has_next_page, has_previous_page: self.has_previous_page, diff --git a/crates/storage/src/personal/access_token.rs b/crates/storage/src/personal/access_token.rs new file mode 100644 index 000000000..363a3199f --- /dev/null +++ b/crates/storage/src/personal/access_token.rs @@ -0,0 +1,140 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use async_trait::async_trait; +use chrono::Duration; +use mas_data_model::{ + Clock, + personal::{PersonalAccessToken, session::PersonalSession}, +}; +use rand_core::RngCore; +use ulid::Ulid; + +use crate::repository_impl; + +/// An [`PersonalAccessTokenRepository`] helps interacting with +/// [`PersonalAccessToken`] saved in the storage backend +#[async_trait] +pub trait PersonalAccessTokenRepository: Send + Sync { + /// The error type returned by the repository + type Error; + + /// Lookup an access token by its ID + /// + /// Returns the access token if it exists, `None` otherwise + /// + /// # Parameters + /// + /// * `id`: The ID of the access token to lookup + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + + /// Find an access token by its token + /// + /// Returns the access token if it exists, `None` otherwise + /// + /// # Parameters + /// + /// * `access_token`: The token of the access token to lookup + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn find_by_token( + &mut self, + access_token: &str, + ) -> Result, Self::Error>; + + /// Find the active access token belonging to a given session. + /// + /// Returns the active access token if it exists, `None` otherwise + /// + /// # Parameters + /// + /// * `session`: The session to lookup + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn find_active_for_session( + &mut self, + session: &PersonalSession, + ) -> Result, Self::Error>; + + /// Add a new access token to the database + /// + /// Returns the newly created access token + /// + /// # Parameters + /// + /// * `rng`: A random number generator + /// * `clock`: The clock used to generate timestamps + /// * `session`: The session the access token is associated with + /// * `access_token`: The access token to add + /// * `expires_after`: The duration after which the access token expires. If + /// [`None`] the access token never expires + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + session: &PersonalSession, + access_token: &str, + expires_after: Option, + ) -> Result; + + /// Revoke an access token + /// + /// Returns the revoked access token + /// + /// # Parameters + /// + /// * `clock`: The clock used to generate timestamps + /// * `access_token`: The access token to revoke + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn revoke( + &mut self, + clock: &dyn Clock, + access_token: PersonalAccessToken, + ) -> Result; +} + +repository_impl!(PersonalAccessTokenRepository: + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + + async fn find_by_token( + &mut self, + access_token: &str, + ) -> Result, Self::Error>; + + async fn find_active_for_session( + &mut self, + session: &PersonalSession, + ) -> Result, Self::Error>; + + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + session: &PersonalSession, + access_token: &str, + expires_after: Option, + ) -> Result; + + async fn revoke( + &mut self, + clock: &dyn Clock, + access_token: PersonalAccessToken, + ) -> Result; +); diff --git a/crates/storage/src/personal/mod.rs b/crates/storage/src/personal/mod.rs new file mode 100644 index 000000000..3a9dfcd65 --- /dev/null +++ b/crates/storage/src/personal/mod.rs @@ -0,0 +1,16 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +//! Repositories to deal with Personal Sessions and Personal Access Tokens +//! (PATs), which are sessions/access tokens created manually by users for use +//! in scripts, bots and similar applications. + +mod access_token; +mod session; + +pub use self::{ + access_token::PersonalAccessTokenRepository, + session::{PersonalSessionFilter, PersonalSessionRepository, PersonalSessionState}, +}; diff --git a/crates/storage/src/personal/session.rs b/crates/storage/src/personal/session.rs new file mode 100644 index 000000000..921c6df39 --- /dev/null +++ b/crates/storage/src/personal/session.rs @@ -0,0 +1,398 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use std::net::IpAddr; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use mas_data_model::{ + Client, Clock, Device, User, + personal::{ + PersonalAccessToken, + session::{PersonalSession, PersonalSessionOwner}, + }, +}; +use oauth2_types::scope::Scope; +use rand_core::RngCore; +use ulid::Ulid; + +use crate::{Page, Pagination, repository_impl}; + +/// A [`PersonalSessionRepository`] helps interacting with +/// [`PersonalSession`] saved in the storage backend +#[async_trait] +pub trait PersonalSessionRepository: Send + Sync { + /// The error type returned by the repository + type Error; + + /// Lookup a Personal session by its ID + /// + /// Returns the Personal session if it exists, `None` otherwise + /// + /// # Parameters + /// + /// * `id`: The ID of the Personal session to lookup + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + + /// Start a new Personal session + /// + /// Returns the newly created Personal session + /// + /// # Parameters + /// + /// * `rng`: The random number generator to use + /// * `clock`: The clock used to generate timestamps + /// * `owner_user`: The user that will own the personal session + /// * `actor_user`: The user that will be represented by the personal + /// session + /// * `device`: The device ID of this session + /// * `human_name`: The human-readable name of the session provided by the + /// client or the user + /// * `scope`: The [`Scope`] of the [`PersonalSession`] + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + owner: PersonalSessionOwner, + actor_user: &User, + human_name: String, + scope: Scope, + ) -> Result; + + /// End a Personal session + /// + /// Returns the ended Personal session + /// + /// # Parameters + /// + /// * `clock`: The clock used to generate timestamps + /// * `Personal_session`: The Personal session to end + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn revoke( + &mut self, + clock: &dyn Clock, + personal_session: PersonalSession, + ) -> Result; + + /// Revoke all the [`PersonalSession`]s matching the given filter. + /// + /// Returns the number of sessions affected + /// + /// # Parameters + /// + /// * `clock`: The clock used to generate timestamps + /// * `filter`: The filter to apply + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn revoke_bulk( + &mut self, + clock: &dyn Clock, + filter: PersonalSessionFilter<'_>, + ) -> Result; + + /// List [`PersonalSession`]s matching the given filter and pagination + /// parameters + /// + /// # Parameters + /// + /// * `filter`: The filter parameters + /// * `pagination`: The pagination parameters + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn list( + &mut self, + filter: PersonalSessionFilter<'_>, + pagination: Pagination, + ) -> Result)>, Self::Error>; + + /// Count [`PersonalSession`]s matching the given filter + /// + /// # Parameters + /// + /// * `filter`: The filter parameters + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn count(&mut self, filter: PersonalSessionFilter<'_>) -> Result; + + /// Record a batch of [`PersonalSession`] activity + /// + /// # Parameters + /// + /// * `activity`: A list of tuples containing the session ID, the last + /// activity timestamp and the IP address of the client + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn record_batch_activity( + &mut self, + activity: Vec<(Ulid, DateTime, Option)>, + ) -> Result<(), Self::Error>; +} + +repository_impl!(PersonalSessionRepository: + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + owner: PersonalSessionOwner, + actor_user: &User, + human_name: String, + scope: Scope, + ) -> Result; + + async fn revoke( + &mut self, + clock: &dyn Clock, + personal_session: PersonalSession, + ) -> Result; + + async fn revoke_bulk( + &mut self, + clock: &dyn Clock, + filter: PersonalSessionFilter<'_>, + ) -> Result; + + async fn list( + &mut self, + filter: PersonalSessionFilter<'_>, + pagination: Pagination, + ) -> Result)>, Self::Error>; + + async fn count(&mut self, filter: PersonalSessionFilter<'_>) -> Result; + + async fn record_batch_activity( + &mut self, + activity: Vec<(Ulid, DateTime, Option)>, + ) -> Result<(), Self::Error>; +); + +/// Filter parameters for listing personal sessions alongside personal access +/// tokens +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct PersonalSessionFilter<'a> { + owner_user: Option<&'a User>, + owner_oauth2_client: Option<&'a Client>, + actor_user: Option<&'a User>, + device: Option<&'a Device>, + state: Option, + scope: Option<&'a Scope>, + last_active_before: Option>, + last_active_after: Option>, + expires_before: Option>, + expires_after: Option>, + expires: Option, +} + +/// Filter for what state a personal session is in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PersonalSessionState { + /// The personal session is active, which means it either + /// has active access tokens or can have new access tokens generated. + Active, + /// The personal session is revoked, which means no more access tokens + /// can be generated and none are active. + Revoked, +} + +impl<'a> PersonalSessionFilter<'a> { + /// Create a new [`PersonalSessionFilter`] with default values + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// List sessions owned by a specific user + #[must_use] + pub fn for_owner_user(mut self, user: &'a User) -> Self { + self.owner_user = Some(user); + self + } + + /// Get the owner user filter + /// + /// Returns [`None`] if no user filter was set + #[must_use] + pub fn owner_oauth2_client(&self) -> Option<&'a Client> { + self.owner_oauth2_client + } + + /// List sessions owned by a specific user + #[must_use] + pub fn for_owner_oauth2_client(mut self, client: &'a Client) -> Self { + self.owner_oauth2_client = Some(client); + self + } + + /// Get the owner user filter + /// + /// Returns [`None`] if no user filter was set + #[must_use] + pub fn owner_user(&self) -> Option<&'a User> { + self.owner_user + } + + /// List sessions acting as a specific user + #[must_use] + pub fn for_actor_user(mut self, user: &'a User) -> Self { + self.actor_user = Some(user); + self + } + + /// Get the actor user filter + /// + /// Returns [`None`] if no user filter was set + #[must_use] + pub fn actor_user(&self) -> Option<&'a User> { + self.actor_user + } + + /// Only return sessions with a last active time before the given time + #[must_use] + pub fn with_last_active_before(mut self, last_active_before: DateTime) -> Self { + self.last_active_before = Some(last_active_before); + self + } + + /// Only return sessions with a last active time after the given time + #[must_use] + pub fn with_last_active_after(mut self, last_active_after: DateTime) -> Self { + self.last_active_after = Some(last_active_after); + self + } + + /// Get the last active before filter + /// + /// Returns [`None`] if no client filter was set + #[must_use] + pub fn last_active_before(&self) -> Option> { + self.last_active_before + } + + /// Get the last active after filter + /// + /// Returns [`None`] if no client filter was set + #[must_use] + pub fn last_active_after(&self) -> Option> { + self.last_active_after + } + + /// Only return active sessions + #[must_use] + pub fn active_only(mut self) -> Self { + self.state = Some(PersonalSessionState::Active); + self + } + + /// Only return finished sessions + #[must_use] + pub fn finished_only(mut self) -> Self { + self.state = Some(PersonalSessionState::Revoked); + self + } + + /// Get the state filter + /// + /// Returns [`None`] if no state filter was set + #[must_use] + pub fn state(&self) -> Option { + self.state + } + + /// Only return sessions with the given scope + #[must_use] + pub fn with_scope(mut self, scope: &'a Scope) -> Self { + self.scope = Some(scope); + self + } + + /// Get the scope filter + /// + /// Returns [`None`] if no scope filter was set + #[must_use] + pub fn scope(&self) -> Option<&'a Scope> { + self.scope + } + + /// Only return sessions that have the given device in their scope + #[must_use] + pub fn for_device(mut self, device: &'a Device) -> Self { + self.device = Some(device); + self + } + + /// Get the device filter + /// + /// Returns [`None`] if no device filter was set + #[must_use] + pub fn device(&self) -> Option<&'a Device> { + self.device + } + + /// Only return sessions whose access tokens expire before the given time + #[must_use] + pub fn with_expires_before(mut self, expires_before: DateTime) -> Self { + self.expires_before = Some(expires_before); + self + } + + /// Get the expires before filter + /// + /// Returns [`None`] if no expires before filter was set + #[must_use] + pub fn expires_before(&self) -> Option> { + self.expires_before + } + + /// Only return sessions whose access tokens expire after the given time + #[must_use] + pub fn with_expires_after(mut self, expires_after: DateTime) -> Self { + self.expires_after = Some(expires_after); + self + } + + /// Get the expires after filter + /// + /// Returns [`None`] if no expires after filter was set + #[must_use] + pub fn expires_after(&self) -> Option> { + self.expires_after + } + + /// Only return sessions whose access tokens have, or don't have, + /// an expiry time set + #[must_use] + pub fn with_expires(mut self, expires: bool) -> Self { + self.expires = Some(expires); + self + } + + /// Get the expires filter + /// + /// Returns [`None`] if no expires filter was set + #[must_use] + pub fn expires(&self) -> Option { + self.expires + } +} diff --git a/crates/storage/src/queue/tasks.rs b/crates/storage/src/queue/tasks.rs index eb16f6e29..3558314cf 100644 --- a/crates/storage/src/queue/tasks.rs +++ b/crates/storage/src/queue/tasks.rs @@ -384,7 +384,7 @@ impl ExpireInactiveOAuthSessionsJob { let last_edge = page.edges.last()?; Some(Self { threshold: self.threshold, - after: Some(last_edge.id), + after: Some(last_edge.cursor), }) } } @@ -441,7 +441,7 @@ impl ExpireInactiveCompatSessionsJob { let last_edge = page.edges.last()?; Some(Self { threshold: self.threshold, - after: Some(last_edge.id), + after: Some(last_edge.cursor), }) } } @@ -498,7 +498,7 @@ impl ExpireInactiveUserSessionsJob { let last_edge = page.edges.last()?; Some(Self { threshold: self.threshold, - after: Some(last_edge.id), + after: Some(last_edge.cursor), }) } } diff --git a/crates/storage/src/repository.rs b/crates/storage/src/repository.rs index 518769eb1..f6eb191e6 100644 --- a/crates/storage/src/repository.rs +++ b/crates/storage/src/repository.rs @@ -18,6 +18,7 @@ use crate::{ OAuth2AccessTokenRepository, OAuth2AuthorizationGrantRepository, OAuth2ClientRepository, OAuth2DeviceCodeGrantRepository, OAuth2RefreshTokenRepository, OAuth2SessionRepository, }, + personal::{PersonalAccessTokenRepository, PersonalSessionRepository}, policy_data::PolicyDataRepository, queue::{QueueJobRepository, QueueScheduleRepository, QueueWorkerRepository}, upstream_oauth2::{ @@ -214,6 +215,16 @@ pub trait RepositoryAccess: Send { &'c mut self, ) -> Box + 'c>; + /// Get a [`PersonalAccessTokenRepository`] + fn personal_access_token<'c>( + &'c mut self, + ) -> Box + 'c>; + + /// Get a [`PersonalSessionRepository`] + fn personal_session<'c>( + &'c mut self, + ) -> Box + 'c>; + /// Get a [`QueueWorkerRepository`] fn queue_worker<'c>(&'c mut self) -> Box + 'c>; @@ -247,6 +258,7 @@ mod impls { OAuth2ClientRepository, OAuth2DeviceCodeGrantRepository, OAuth2RefreshTokenRepository, OAuth2SessionRepository, }, + personal::{PersonalAccessTokenRepository, PersonalSessionRepository}, policy_data::PolicyDataRepository, queue::{QueueJobRepository, QueueScheduleRepository, QueueWorkerRepository}, upstream_oauth2::{ @@ -458,6 +470,21 @@ mod impls { )) } + fn personal_access_token<'c>( + &'c mut self, + ) -> Box + 'c> { + Box::new(MapErr::new( + self.inner.personal_access_token(), + &mut self.mapper, + )) + } + + fn personal_session<'c>( + &'c mut self, + ) -> Box + 'c> { + Box::new(MapErr::new(self.inner.personal_session(), &mut self.mapper)) + } + fn queue_worker<'c>( &'c mut self, ) -> Box + 'c> { @@ -610,6 +637,18 @@ mod impls { (**self).compat_refresh_token() } + fn personal_access_token<'c>( + &'c mut self, + ) -> Box + 'c> { + (**self).personal_access_token() + } + + fn personal_session<'c>( + &'c mut self, + ) -> Box + 'c> { + (**self).personal_session() + } + fn queue_worker<'c>( &'c mut self, ) -> Box + 'c> { diff --git a/crates/tasks/src/matrix.rs b/crates/tasks/src/matrix.rs index 87e052d20..68905fe53 100644 --- a/crates/tasks/src/matrix.rs +++ b/crates/tasks/src/matrix.rs @@ -14,6 +14,7 @@ use mas_storage::{ Pagination, RepositoryAccess, compat::CompatSessionFilter, oauth2::OAuth2SessionFilter, + personal::PersonalSessionFilter, queue::{ DeleteDeviceJob, ProvisionDeviceJob, ProvisionUserJob, QueueJobRepositoryExt as _, SyncDevicesJob, @@ -203,11 +204,12 @@ impl RunnableJob for SyncDevicesJob { .await .map_err(JobError::retry)?; - for (compat_session, _) in page.edges { + for edge in page.edges { + let (compat_session, _) = edge.node; if let Some(ref device) = compat_session.device { devices.insert(device.as_str().to_owned()); } - cursor = cursor.after(compat_session.id); + cursor = cursor.after(edge.cursor); } if !page.has_next_page { @@ -227,14 +229,44 @@ impl RunnableJob for SyncDevicesJob { .await .map_err(JobError::retry)?; - for oauth2_session in page.edges { - for scope in &*oauth2_session.scope { + for edge in page.edges { + for scope in &*edge.node.scope { if let Some(device) = Device::from_scope_token(scope) { devices.insert(device.as_str().to_owned()); } } - cursor = cursor.after(oauth2_session.id); + cursor = cursor.after(edge.cursor); + } + + if !page.has_next_page { + break; + } + } + + // Cycle through all the personal sessions of the user and get the devices + let mut cursor = Pagination::first(5000); + loop { + let page = repo + .personal_session() + .list( + PersonalSessionFilter::new() + .for_actor_user(&user) + .active_only(), + cursor, + ) + .await + .map_err(JobError::retry)?; + + for edge in page.edges { + let (session, _) = &edge.node; + for scope in &*session.scope { + if let Some(device) = Device::from_scope_token(scope) { + devices.insert(device.as_str().to_owned()); + } + } + + cursor = cursor.after(edge.cursor); } if !page.has_next_page { diff --git a/crates/tasks/src/recovery.rs b/crates/tasks/src/recovery.rs index 03e02d57b..51afcc295 100644 --- a/crates/tasks/src/recovery.rs +++ b/crates/tasks/src/recovery.rs @@ -70,26 +70,18 @@ impl RunnableJob for SendAccountRecoveryEmailsJob { .await .map_err(JobError::retry)?; - for email in page.edges { + for edge in page.edges { let ticket = Alphanumeric.sample_string(&mut rng, 32); let ticket = repo .user_recovery() - .add_ticket(&mut rng, clock, &session, &email, ticket) + .add_ticket(&mut rng, clock, &session, &edge.node, ticket) .await .map_err(JobError::retry)?; - let user_email = repo - .user_email() - .lookup(email.id) - .await - .map_err(JobError::retry)? - .context("User email not found") - .map_err(JobError::fail)?; - let user = repo .user() - .lookup(user_email.user_id) + .lookup(edge.node.user_id) .await .map_err(JobError::retry)? .context("User not found") @@ -97,7 +89,7 @@ impl RunnableJob for SendAccountRecoveryEmailsJob { let url = url_builder.account_recovery_link(ticket.ticket); - let address: Address = user_email.email.parse().map_err(JobError::fail)?; + let address: Address = edge.node.email.parse().map_err(JobError::fail)?; let mailbox = Mailbox::new(Some(user.username.clone()), address); info!("Sending recovery email to {}", mailbox); @@ -112,7 +104,7 @@ impl RunnableJob for SendAccountRecoveryEmailsJob { ); } - cursor = cursor.after(email.id); + cursor = cursor.after(edge.cursor); } if !page.has_next_page { diff --git a/crates/tasks/src/sessions.rs b/crates/tasks/src/sessions.rs index d10d908da..eede69d51 100644 --- a/crates/tasks/src/sessions.rs +++ b/crates/tasks/src/sessions.rs @@ -110,7 +110,7 @@ impl RunnableJob for ExpireInactiveOAuthSessionsJob { } for edge in page.edges { - if let Some(user_id) = edge.user_id { + if let Some(user_id) = edge.node.user_id { let inserted = users_synced.insert(user_id); if inserted { tracing::info!(user.id = %user_id, "Scheduling devices sync for user"); @@ -128,7 +128,7 @@ impl RunnableJob for ExpireInactiveOAuthSessionsJob { } repo.oauth2_session() - .finish(clock, edge) + .finish(clock, edge.node) .await .map_err(JobError::retry)?; } @@ -174,14 +174,14 @@ impl RunnableJob for ExpireInactiveCompatSessionsJob { } for edge in page.edges { - let inserted = users_synced.insert(edge.user_id); + let inserted = users_synced.insert(edge.node.user_id); if inserted { - tracing::info!(user.id = %edge.user_id, "Scheduling devices sync for user"); + tracing::info!(user.id = %edge.node.user_id, "Scheduling devices sync for user"); repo.queue_job() .schedule_job_later( &mut rng, clock, - SyncDevicesJob::new_for_id(edge.user_id), + SyncDevicesJob::new_for_id(edge.node.user_id), clock.now() + delay, ) .await @@ -190,7 +190,7 @@ impl RunnableJob for ExpireInactiveCompatSessionsJob { } repo.compat_session() - .finish(clock, edge) + .finish(clock, edge.node) .await .map_err(JobError::retry)?; } @@ -230,7 +230,7 @@ impl RunnableJob for ExpireInactiveUserSessionsJob { for edge in page.edges { repo.browser_session() - .finish(clock, edge) + .finish(clock, edge.node) .await .map_err(JobError::retry)?; } diff --git a/crates/tasks/src/user.rs b/crates/tasks/src/user.rs index ee60c6532..e605670cc 100644 --- a/crates/tasks/src/user.rs +++ b/crates/tasks/src/user.rs @@ -10,6 +10,7 @@ use mas_storage::{ RepositoryAccess, compat::CompatSessionFilter, oauth2::OAuth2SessionFilter, + personal::PersonalSessionFilter, queue::{DeactivateUserJob, ReactivateUserJob}, user::{BrowserSessionFilter, UserEmailFilter, UserRepository}, }; @@ -80,6 +81,36 @@ impl RunnableJob for DeactivateUserJob { .map_err(JobError::retry)?; info!(affected = n, "Killed all compatibility sessions for user"); + let n = repo + .personal_session() + .revoke_bulk( + clock, + PersonalSessionFilter::new() + .for_actor_user(&user) + .active_only(), + ) + .await + .map_err(JobError::retry)?; + info!( + affected = n, + "Killed all compatibility sessions acting as user" + ); + + let n = repo + .personal_session() + .revoke_bulk( + clock, + PersonalSessionFilter::new() + .for_owner_user(&user) + .active_only(), + ) + .await + .map_err(JobError::retry)?; + info!( + affected = n, + "Killed all compatibility sessions owned by user" + ); + // Delete all the email addresses for the user let n = repo .user_email() diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 0a44ec547..6646cae46 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -12,6 +12,7 @@ mod ext; mod features; use std::{ + collections::BTreeMap, fmt::Formatter, net::{IpAddr, Ipv4Addr}, }; @@ -105,21 +106,53 @@ pub trait TemplateContext: Serialize { /// /// This is then used to check for template validity in unit tests and in /// the CLI (`cargo run -- templates check`) - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized; } +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct SampleIdentifier { + pub components: Vec<(&'static str, String)>, +} + +impl SampleIdentifier { + pub fn from_index(index: usize) -> Self { + Self { + components: Vec::default(), + } + .with_appended("index", format!("{index}")) + } + + pub fn with_appended(&self, kind: &'static str, locale: String) -> Self { + let mut new = self.clone(); + new.components.push((kind, locale)); + new + } +} + +pub(crate) fn sample_list(samples: Vec) -> BTreeMap { + samples + .into_iter() + .enumerate() + .map(|(index, sample)| (SampleIdentifier::from_index(index), sample)) + .collect() +} + impl TemplateContext for () { fn sample( _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - Vec::new() + BTreeMap::new() } } @@ -148,7 +181,11 @@ impl std::ops::Deref for WithLanguage { } impl TemplateContext for WithLanguage { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { @@ -157,9 +194,14 @@ impl TemplateContext for WithLanguage { .flat_map(|locale| { T::sample(now, rng, locales) .into_iter() - .map(move |inner| WithLanguage { - lang: locale.to_string(), - inner, + .map(|(sample_id, sample)| { + ( + sample_id.with_appended("locale", locale.to_string()), + WithLanguage { + lang: locale.to_string(), + inner: sample, + }, + ) }) }) .collect() @@ -176,15 +218,24 @@ pub struct WithCsrf { } impl TemplateContext for WithCsrf { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { T::sample(now, rng, locales) .into_iter() - .map(|inner| WithCsrf { - csrf_token: "fake_csrf_token".into(), - inner, + .map(|(k, inner)| { + ( + k, + WithCsrf { + csrf_token: "fake_csrf_token".into(), + inner, + }, + ) }) .collect() } @@ -200,18 +251,28 @@ pub struct WithSession { } impl TemplateContext for WithSession { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { BrowserSession::samples(now, rng) .into_iter() - .flat_map(|session| { + .enumerate() + .flat_map(|(session_index, session)| { T::sample(now, rng, locales) .into_iter() - .map(move |inner| WithSession { - current_session: session.clone(), - inner, + .map(move |(k, inner)| { + ( + k.with_appended("browser-session", session_index.to_string()), + WithSession { + current_session: session.clone(), + inner, + }, + ) }) }) .collect() @@ -228,7 +289,11 @@ pub struct WithOptionalSession { } impl TemplateContext for WithOptionalSession { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { @@ -236,12 +301,22 @@ impl TemplateContext for WithOptionalSession { .into_iter() .map(Some) // Wrap all samples in an Option .chain(std::iter::once(None)) // Add the "None" option - .flat_map(|session| { + .enumerate() + .flat_map(|(session_index, session)| { T::sample(now, rng, locales) .into_iter() - .map(move |inner| WithOptionalSession { - current_session: session.clone(), - inner, + .map(move |(k, inner)| { + ( + if session.is_some() { + k.with_appended("browser-session", session_index.to_string()) + } else { + k + }, + WithOptionalSession { + current_session: session.clone(), + inner, + }, + ) }) }) .collect() @@ -269,11 +344,11 @@ impl TemplateContext for EmptyContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![EmptyContext] + sample_list(vec![EmptyContext]) } } @@ -297,15 +372,15 @@ impl TemplateContext for IndexContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![Self { + sample_list(vec![Self { discovery_url: "https://example.com/.well-known/openid-configuration" .parse() .unwrap(), - }] + }]) } } @@ -343,12 +418,12 @@ impl TemplateContext for AppContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None); - vec![Self::from_url_builder(&url_builder)] + sample_list(vec![Self::from_url_builder(&url_builder)]) } } @@ -376,12 +451,12 @@ impl TemplateContext for ApiDocContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None); - vec![Self::from_url_builder(&url_builder)] + sample_list(vec![Self::from_url_builder(&url_builder)]) } } @@ -468,12 +543,12 @@ impl TemplateContext for LoginContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { // TODO: samples with errors - vec![ + sample_list(vec![ LoginContext { form: FormState::default(), next: None, @@ -503,7 +578,7 @@ impl TemplateContext for LoginContext { next: None, providers: Vec::new(), }, - ] + ]) } } @@ -576,14 +651,14 @@ impl TemplateContext for RegisterContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![RegisterContext { + sample_list(vec![RegisterContext { providers: Vec::new(), next: None, - }] + }]) } } @@ -619,15 +694,15 @@ impl TemplateContext for PasswordRegisterContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { // TODO: samples with errors - vec![PasswordRegisterContext { + sample_list(vec![PasswordRegisterContext { form: FormState::default(), next: None, - }] + }]) } } @@ -659,10 +734,15 @@ pub struct ConsentContext { } impl TemplateContext for ConsentContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { +<<<<<<< HEAD Client::samples(now, rng) .into_iter() .map(|client| { @@ -679,6 +759,24 @@ impl TemplateContext for ConsentContext { } }) .collect() +======= + sample_list( + Client::samples(now, rng) + .into_iter() + .map(|client| { + let mut grant = AuthorizationGrant::sample(now, rng); + let action = PostAuthAction::continue_grant(grant.id); + // XXX + grant.client_id = client.id; + Self { + grant, + client, + action, + } + }) + .collect(), + ) +>>>>>>> v1.6.0 } } @@ -724,38 +822,44 @@ pub struct PolicyViolationContext { } impl TemplateContext for PolicyViolationContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - Client::samples(now, rng) - .into_iter() - .flat_map(|client| { - let mut grant = AuthorizationGrant::sample(now, rng); - // XXX - grant.client_id = client.id; - - let authorization_grant = - PolicyViolationContext::for_authorization_grant(grant, client.clone()); - let device_code_grant = PolicyViolationContext::for_device_code_grant( - DeviceCodeGrant { - id: Ulid::from_datetime_with_source(now.into(), rng), - state: mas_data_model::DeviceCodeGrantState::Pending, - client_id: client.id, - scope: [OPENID].into_iter().collect(), - user_code: Alphanumeric.sample_string(rng, 6).to_uppercase(), - device_code: Alphanumeric.sample_string(rng, 32), - created_at: now - Duration::try_minutes(5).unwrap(), - expires_at: now + Duration::try_minutes(25).unwrap(), - ip_address: None, - user_agent: None, - }, - client, - ); + sample_list( + Client::samples(now, rng) + .into_iter() + .flat_map(|client| { + let mut grant = AuthorizationGrant::sample(now, rng); + // XXX + grant.client_id = client.id; + + let authorization_grant = + PolicyViolationContext::for_authorization_grant(grant, client.clone()); + let device_code_grant = PolicyViolationContext::for_device_code_grant( + DeviceCodeGrant { + id: Ulid::from_datetime_with_source(now.into(), rng), + state: mas_data_model::DeviceCodeGrantState::Pending, + client_id: client.id, + scope: [OPENID].into_iter().collect(), + user_code: Alphanumeric.sample_string(rng, 6).to_uppercase(), + device_code: Alphanumeric.sample_string(rng, 32), + created_at: now - Duration::try_minutes(5).unwrap(), + expires_at: now + Duration::try_minutes(25).unwrap(), + ip_address: None, + user_agent: None, + }, + client, + ); - [authorization_grant, device_code_grant] - }) - .collect() + [authorization_grant, device_code_grant] + }) + .collect(), + ) } } @@ -793,18 +897,22 @@ pub struct CompatSsoContext { } impl TemplateContext for CompatSsoContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { let id = Ulid::from_datetime_with_source(now.into(), rng); - vec![CompatSsoContext::new(CompatSsoLogin { + sample_list(vec![CompatSsoContext::new(CompatSsoLogin { id, redirect_uri: Url::parse("https://app.element.io/").unwrap(), login_token: "abcdefghijklmnopqrstuvwxyz012345".into(), created_at: now, state: CompatSsoLoginState::Pending, - })] + })]) } } @@ -851,11 +959,15 @@ impl EmailRecoveryContext { } impl TemplateContext for EmailRecoveryContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - User::samples(now, rng).into_iter().map(|user| { + sample_list(User::samples(now, rng).into_iter().map(|user| { let session = UserRecoverySession { id: Ulid::from_datetime_with_source(now.into(), rng), email: "hello@example.com".to_owned(), @@ -869,7 +981,7 @@ impl TemplateContext for EmailRecoveryContext { let link = "https://example.com/recovery/complete?ticket=abcdefghijklmnopqrstuvwxyz0123456789".parse().unwrap(); Self::new(user, session, link) - }).collect() + }).collect()) } } @@ -912,28 +1024,37 @@ impl EmailVerificationContext { } impl TemplateContext for EmailVerificationContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - BrowserSession::samples(now, rng) - .into_iter() - .map(|browser_session| { - let authentication_code = UserEmailAuthenticationCode { - id: Ulid::from_datetime_with_source(now.into(), rng), - user_email_authentication_id: Ulid::from_datetime_with_source(now.into(), rng), - code: "123456".to_owned(), - created_at: now - Duration::try_minutes(5).unwrap(), - expires_at: now + Duration::try_minutes(25).unwrap(), - }; + sample_list( + BrowserSession::samples(now, rng) + .into_iter() + .map(|browser_session| { + let authentication_code = UserEmailAuthenticationCode { + id: Ulid::from_datetime_with_source(now.into(), rng), + user_email_authentication_id: Ulid::from_datetime_with_source( + now.into(), + rng, + ), + code: "123456".to_owned(), + created_at: now - Duration::try_minutes(5).unwrap(), + expires_at: now + Duration::try_minutes(25).unwrap(), + }; - Self { - browser_session: Some(browser_session), - user_registration: None, - authentication_code, - } - }) - .collect() + Self { + browser_session: Some(browser_session), + user_registration: None, + authentication_code, + } + }) + .collect(), + ) } } @@ -978,7 +1099,11 @@ impl RegisterStepsVerifyEmailContext { } impl TemplateContext for RegisterStepsVerifyEmailContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { @@ -991,10 +1116,10 @@ impl TemplateContext for RegisterStepsVerifyEmailContext { completed_at: None, }; - vec![Self { + sample_list(vec![Self { form: FormState::default(), authentication, - }] + }]) } } @@ -1018,13 +1143,13 @@ impl TemplateContext for RegisterStepsEmailInUseContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { let email = "hello@example.com".to_owned(); let action = PostAuthAction::continue_grant(Ulid::nil()); - vec![Self::new(email, Some(action))] + sample_list(vec![Self::new(email, Some(action))]) } } @@ -1073,13 +1198,13 @@ impl TemplateContext for RegisterStepsDisplayNameContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![Self { + sample_list(vec![Self { form: FormState::default(), - }] + }]) } } @@ -1128,13 +1253,13 @@ impl TemplateContext for RegisterStepsRegistrationTokenContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![Self { + sample_list(vec![Self { form: FormState::default(), - }] + }]) } } @@ -1179,11 +1304,11 @@ impl TemplateContext for RecoveryStartContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![ + sample_list(vec![ Self::new(), Self::new().with_form_state( FormState::default() @@ -1193,7 +1318,7 @@ impl TemplateContext for RecoveryStartContext { FormState::default() .with_error_on_field(RecoveryStartFormField::Email, FieldError::Invalid), ), - ] + ]) } } @@ -1217,7 +1342,11 @@ impl RecoveryProgressContext { } impl TemplateContext for RecoveryProgressContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { @@ -1231,7 +1360,7 @@ impl TemplateContext for RecoveryProgressContext { consumed_at: None, }; - vec![ + sample_list(vec![ Self { session: session.clone(), resend_failed_due_to_rate_limit: false, @@ -1240,7 +1369,7 @@ impl TemplateContext for RecoveryProgressContext { session, resend_failed_due_to_rate_limit: true, }, - ] + ]) } } @@ -1259,7 +1388,11 @@ impl RecoveryExpiredContext { } impl TemplateContext for RecoveryExpiredContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { @@ -1273,10 +1406,9 @@ impl TemplateContext for RecoveryExpiredContext { consumed_at: None, }; - vec![Self { session }] + sample_list(vec![Self { session }]) } } - /// Fields of the account recovery finish form #[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -1320,30 +1452,36 @@ impl RecoveryFinishContext { } impl TemplateContext for RecoveryFinishContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - User::samples(now, rng) - .into_iter() - .flat_map(|user| { - vec![ - Self::new(user.clone()), - Self::new(user.clone()).with_form_state( - FormState::default().with_error_on_field( - RecoveryFinishFormField::NewPassword, - FieldError::Invalid, + sample_list( + User::samples(now, rng) + .into_iter() + .flat_map(|user| { + vec![ + Self::new(user.clone()), + Self::new(user.clone()).with_form_state( + FormState::default().with_error_on_field( + RecoveryFinishFormField::NewPassword, + FieldError::Invalid, + ), ), - ), - Self::new(user.clone()).with_form_state( - FormState::default().with_error_on_field( - RecoveryFinishFormField::NewPasswordConfirm, - FieldError::Invalid, + Self::new(user.clone()).with_form_state( + FormState::default().with_error_on_field( + RecoveryFinishFormField::NewPasswordConfirm, + FieldError::Invalid, + ), ), - ), - ] - }) - .collect() + ] + }) + .collect(), + ) } } @@ -1363,14 +1501,20 @@ impl UpstreamExistingLinkContext { } impl TemplateContext for UpstreamExistingLinkContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - User::samples(now, rng) - .into_iter() - .map(|linked_user| Self { linked_user }) - .collect() + sample_list( + User::samples(now, rng) + .into_iter() + .map(|linked_user| Self { linked_user }) + .collect(), + ) } } @@ -1395,12 +1539,16 @@ impl UpstreamSuggestLink { } impl TemplateContext for UpstreamSuggestLink { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { let id = Ulid::from_datetime_with_source(now.into(), rng); - vec![Self::for_link_id(id)] + sample_list(vec![Self::for_link_id(id)]) } } @@ -1520,11 +1668,15 @@ impl UpstreamRegister { } impl TemplateContext for UpstreamRegister { - fn sample(now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - vec![Self::new( + sample_list(vec![Self::new( UpstreamOAuthLink { id: Ulid::nil(), provider_id: Ulid::nil(), @@ -1560,7 +1712,7 @@ impl TemplateContext for UpstreamRegister { disabled_at: None, on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, }, - )] + )]) } } @@ -1606,17 +1758,17 @@ impl TemplateContext for DeviceLinkContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![ + sample_list(vec![ Self::new(), Self::new().with_form_state( FormState::default() .with_error_on_field(DeviceLinkFormField::Code, FieldError::Required), ), - ] + ]) } } @@ -1636,13 +1788,17 @@ impl DeviceConsentContext { } impl TemplateContext for DeviceConsentContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - Client::samples(now, rng) + sample_list(Client::samples(now, rng) .into_iter() - .map(|client| { + .map(|client| { let grant = DeviceCodeGrant { id: Ulid::from_datetime_with_source(now.into(), rng), state: mas_data_model::DeviceCodeGrantState::Pending, @@ -1657,7 +1813,7 @@ impl TemplateContext for DeviceConsentContext { }; Self { grant, client } }) - .collect() + .collect()) } } @@ -1677,14 +1833,20 @@ impl AccountInactiveContext { } impl TemplateContext for AccountInactiveContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - User::samples(now, rng) - .into_iter() - .map(|user| AccountInactiveContext { user }) - .collect() + sample_list( + User::samples(now, rng) + .into_iter() + .map(|user| AccountInactiveContext { user }) + .collect(), + ) } } @@ -1707,17 +1869,21 @@ impl DeviceNameContext { } impl TemplateContext for DeviceNameContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - Client::samples(now, rng) + sample_list(Client::samples(now, rng) .into_iter() .map(|client| DeviceNameContext { client, raw_user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.0.0 Safari/537.36".to_owned(), }) - .collect() + .collect()) } } @@ -1729,16 +1895,25 @@ pub struct FormPostContext { } impl TemplateContext for FormPostContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { let sample_params = T::sample(now, rng, locales); sample_params .into_iter() - .map(|params| FormPostContext { - redirect_uri: "https://example.com/callback".parse().ok(), - params, + .map(|(k, params)| { + ( + k, + FormPostContext { + redirect_uri: "https://example.com/callback".parse().ok(), + params, + }, + ) }) .collect() } @@ -1806,18 +1981,18 @@ impl TemplateContext for ErrorContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![ + sample_list(vec![ Self::new() .with_code("sample_error") .with_description("A fancy description".into()) .with_details("Something happened".into()), Self::new().with_code("another_error"), Self::new(), - ] + ]) } } @@ -1896,11 +2071,15 @@ impl NotFoundContext { } impl TemplateContext for NotFoundContext { - fn sample(_now: DateTime, _rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + _now: DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - vec![ + sample_list(vec![ Self::new(&Method::GET, Version::HTTP_11, &"/".parse().unwrap()), Self::new(&Method::POST, Version::HTTP_2, &"/foo/bar".parse().unwrap()), Self::new( @@ -1908,6 +2087,6 @@ impl TemplateContext for NotFoundContext { Version::HTTP_10, &"/foo?bar=baz".parse().unwrap(), ), - ] + ]) } } diff --git a/crates/templates/src/context/branding.rs b/crates/templates/src/context/branding.rs index eb7e3546a..15932567f 100644 --- a/crates/templates/src/context/branding.rs +++ b/crates/templates/src/context/branding.rs @@ -58,9 +58,9 @@ impl Object for SiteBranding { fn get_value(self: &Arc, name: &Value) -> Option { match name.as_str()? { "server_name" => Some(self.server_name.clone().into()), - "policy_uri" => self.policy_uri.clone().map(Value::from), - "tos_uri" => self.tos_uri.clone().map(Value::from), - "imprint" => self.imprint.clone().map(Value::from), + "policy_uri" => Some(Value::from(self.policy_uri.clone())), + "tos_uri" => Some(Value::from(self.tos_uri.clone())), + "imprint" => Some(Value::from(self.imprint.clone())), _ => None, } } diff --git a/crates/templates/src/context/captcha.rs b/crates/templates/src/context/captcha.rs index 442cea4f8..3daafb745 100644 --- a/crates/templates/src/context/captcha.rs +++ b/crates/templates/src/context/captcha.rs @@ -4,7 +4,7 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. -use std::sync::Arc; +use std::{collections::BTreeMap, sync::Arc}; use mas_i18n::DataLocale; use minijinja::{ @@ -13,7 +13,7 @@ use minijinja::{ }; use serde::Serialize; -use crate::TemplateContext; +use crate::{TemplateContext, context::SampleIdentifier}; #[derive(Debug)] struct CaptchaConfig(mas_data_model::CaptchaConfig); @@ -62,14 +62,13 @@ impl TemplateContext for WithCaptcha { now: chrono::DateTime, rng: &mut impl rand::prelude::Rng, locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - let inner = T::sample(now, rng, locales); - inner + T::sample(now, rng, locales) .into_iter() - .map(|inner| Self::new(None, inner)) + .map(|(k, inner)| (k, Self::new(None, inner))) .collect() } } diff --git a/crates/templates/src/context/ext.rs b/crates/templates/src/context/ext.rs index e4ae3886c..679ad91a7 100644 --- a/crates/templates/src/context/ext.rs +++ b/crates/templates/src/context/ext.rs @@ -45,6 +45,7 @@ impl SiteConfigExt for SiteConfig { fn templates_features(&self) -> SiteFeatures { SiteFeatures { password_registration: self.password_registration_enabled, + password_registration_email_required: self.password_registration_email_required, password_login: self.password_login_enabled, account_recovery: self.account_recovery_allowed, login_with_email_allowed: self.login_with_email_allowed, diff --git a/crates/templates/src/context/features.rs b/crates/templates/src/context/features.rs index d514b5c63..07e80f702 100644 --- a/crates/templates/src/context/features.rs +++ b/crates/templates/src/context/features.rs @@ -18,6 +18,9 @@ pub struct SiteFeatures { /// Whether local password-based registration is enabled. pub password_registration: bool, + /// Whether local password-based registration requires an email address. + pub password_registration_email_required: bool, + /// Whether local password-based login is enabled. pub password_login: bool, @@ -32,6 +35,9 @@ impl Object for SiteFeatures { fn get_value(self: &Arc, field: &Value) -> Option { match field.as_str()? { "password_registration" => Some(Value::from(self.password_registration)), + "password_registration_email_required" => { + Some(Value::from(self.password_registration_email_required)) + } "password_login" => Some(Value::from(self.password_login)), "account_recovery" => Some(Value::from(self.account_recovery)), "login_with_email_allowed" => Some(Value::from(self.login_with_email_allowed)), @@ -42,6 +48,7 @@ impl Object for SiteFeatures { fn enumerate(self: &Arc) -> Enumerator { Enumerator::Str(&[ "password_registration", + "password_registration_email_required", "password_login", "account_recovery", "login_with_email_allowed", diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 1c0bef423..603dcfdf6 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -9,7 +9,10 @@ //! Templates rendering -use std::{collections::HashSet, sync::Arc}; +use std::{ + collections::{BTreeMap, HashSet}, + sync::Arc, +}; use anyhow::Context as _; use arc_swap::ArcSwap; @@ -17,7 +20,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use mas_i18n::Translator; use mas_router::UrlBuilder; use mas_spa::ViteManifest; -use minijinja::Value; +use minijinja::{UndefinedBehavior, Value}; use rand::Rng; use serde::Serialize; use thiserror::Error; @@ -50,6 +53,7 @@ pub use self::{ }, forms::{FieldError, FormError, FormField, FormState, ToFormState}, }; +use crate::context::SampleIdentifier; /// Escape the given string for use in HTML /// @@ -71,6 +75,9 @@ pub struct Templates { vite_manifest_path: Utf8PathBuf, translations_path: Utf8PathBuf, path: Utf8PathBuf, + /// Whether template rendering is in strict mode (for testing, + /// until this can be rolled out in production.) + strict: bool, } /// There was an issue while loading the templates @@ -151,6 +158,7 @@ impl Templates { translations_path: Utf8PathBuf, branding: SiteBranding, features: SiteFeatures, + strict: bool, ) -> Result { let (translator, environment) = Self::load_( &path, @@ -159,6 +167,7 @@ impl Templates { &translations_path, branding.clone(), features, + strict, ) .await?; Ok(Self { @@ -170,6 +179,7 @@ impl Templates { translations_path, branding, features, + strict, }) } @@ -180,6 +190,7 @@ impl Templates { translations_path: &Utf8Path, branding: SiteBranding, features: SiteFeatures, + strict: bool, ) -> Result<(Arc, Arc>), TemplateLoadingError> { let path = path.to_owned(); let span = tracing::Span::current(); @@ -205,6 +216,15 @@ impl Templates { span.in_scope(move || { let mut loaded: HashSet<_> = HashSet::new(); let mut env = minijinja::Environment::new(); + // Don't allow use of undefined variables + env.set_undefined_behavior(if strict { + UndefinedBehavior::Strict + } else { + // For now, allow semi-strict, because we don't have total test coverage of + // tests and some tests rely on if conditions against sometimes-undefined + // variables + UndefinedBehavior::SemiStrict + }); let root = path.canonicalize_utf8()?; info!(%root, "Loading templates from filesystem"); for entry in walkdir::WalkDir::new(&root) @@ -275,6 +295,7 @@ impl Templates { &self.translations_path, self.branding.clone(), self.features, + self.strict, ) .await?; @@ -383,7 +404,7 @@ register_templates! { pub fn render_recovery_disabled(WithLanguage) { "pages/recovery/disabled.html" } /// Render the form used by the `form_post` response mode - pub fn render_form_post(WithLanguage>) { "form_post.html" } + pub fn render_form_post<#[sample(EmptyContext)] T: Serialize>(WithLanguage>) { "form_post.html" } /// Render the HTML error page pub fn render_error(ErrorContext) { "pages/error.html" } @@ -439,7 +460,13 @@ register_templates! { impl Templates { /// Render all templates with the generated samples to check if they render - /// properly + /// properly. + /// + /// Returns the renders in a map whose keys are template names + /// and the values are lists of renders (according to the list + /// of samples). + /// Samples are stable across re-runs and can be used for + /// acceptance testing. /// /// # Errors /// @@ -448,47 +475,8 @@ impl Templates { &self, now: chrono::DateTime, rng: &mut impl Rng, - ) -> anyhow::Result<()> { - check::render_not_found(self, now, rng)?; - check::render_app(self, now, rng)?; - check::render_swagger(self, now, rng)?; - check::render_swagger_callback(self, now, rng)?; - check::render_login(self, now, rng)?; - check::render_register(self, now, rng)?; - check::render_password_register(self, now, rng)?; - check::render_register_steps_verify_email(self, now, rng)?; - check::render_register_steps_email_in_use(self, now, rng)?; - check::render_register_steps_display_name(self, now, rng)?; - check::render_register_steps_registration_token(self, now, rng)?; - check::render_consent(self, now, rng)?; - check::render_policy_violation(self, now, rng)?; - check::render_sso_login(self, now, rng)?; - check::render_index(self, now, rng)?; - check::render_recovery_start(self, now, rng)?; - check::render_recovery_progress(self, now, rng)?; - check::render_recovery_finish(self, now, rng)?; - check::render_recovery_expired(self, now, rng)?; - check::render_recovery_consumed(self, now, rng)?; - check::render_recovery_disabled(self, now, rng)?; - check::render_form_post::(self, now, rng)?; - check::render_error(self, now, rng)?; - check::render_email_recovery_txt(self, now, rng)?; - check::render_email_recovery_html(self, now, rng)?; - check::render_email_recovery_subject(self, now, rng)?; - check::render_email_verification_txt(self, now, rng)?; - check::render_email_verification_html(self, now, rng)?; - check::render_email_verification_subject(self, now, rng)?; - check::render_upstream_oauth2_link_mismatch(self, now, rng)?; - check::render_upstream_oauth2_login_link(self, now, rng)?; - check::render_upstream_oauth2_suggest_link(self, now, rng)?; - check::render_upstream_oauth2_do_register(self, now, rng)?; - check::render_device_link(self, now, rng)?; - check::render_device_consent(self, now, rng)?; - check::render_account_deactivated(self, now, rng)?; - check::render_account_locked(self, now, rng)?; - check::render_account_logged_out(self, now, rng)?; - check::render_device_name(self, now, rng)?; - Ok(()) + ) -> anyhow::Result> { + check::all(self, now, rng) } } @@ -509,6 +497,7 @@ mod tests { let features = SiteFeatures { password_login: true, password_registration: true, + password_registration_email_required: true, account_recovery: true, login_with_email_allowed: true, }; @@ -523,6 +512,8 @@ mod tests { translations_path, branding, features, + // Use strict mode in tests + true, ) .await .unwrap(); diff --git a/crates/templates/src/macros.rs b/crates/templates/src/macros.rs index e0b203735..95b57f0d9 100644 --- a/crates/templates/src/macros.rs +++ b/crates/templates/src/macros.rs @@ -31,7 +31,9 @@ macro_rules! register_templates { pub fn $name:ident // Optional list of generics. Taken from // https://newbedev.com/rust-macro-accepting-type-with-generic-parameters - $(< $( $lt:tt $( : $clt:tt $(+ $dlt:tt )* )? ),+ >)? + // For sample rendering, we also require a 'sample' generic parameter to be provided, + // using #[sample(Type)] attribute syntax + $(< $( #[sample( $generic_default:tt )] $lt:tt $( : $clt:tt $(+ $dlt:tt )* )? ),+ >)? // Type of context taken by the template ( $param:ty ) { @@ -69,28 +71,53 @@ macro_rules! register_templates { pub mod check { use super::*; + /// Check and render all templates with all samples. + /// + /// Returns the sample renders. The keys in the map are the template names. + /// + /// # Errors + /// + /// Returns an error if any template fails to render with any of the sample. + pub(crate) fn all(templates: &Templates, now: chrono::DateTime, rng: &mut impl rand::Rng) -> anyhow::Result<::std::collections::BTreeMap<(&'static str, SampleIdentifier), String>> { + let mut out = ::std::collections::BTreeMap::new(); + // TODO shouldn't the Rng be independent for each render? + $( + out.extend( + $name $(::< $( $generic_default ),* >)? (templates, now, rng)? + .into_iter() + .map(|(sample_identifier, rendered)| (($template, sample_identifier), rendered)) + ); + )* + + Ok(out) + } + $( #[doc = concat!("Render the `", $template, "` template with sample contexts")] /// + /// Returns the sample renders. + /// /// # Errors /// /// Returns an error if the template fails to render with any of the sample. pub(crate) fn $name $(< $( $lt $( : $clt $(+ $dlt )* + TemplateContext )? ),+ >)? (templates: &Templates, now: chrono::DateTime, rng: &mut impl rand::Rng) - -> anyhow::Result<()> { + -> anyhow::Result> { let locales = templates.translator().available_locales(); - let samples: Vec< $param > = TemplateContext::sample(now, rng, &locales); + let samples: BTreeMap = TemplateContext::sample(now, rng, &locales); let name = $template; - for sample in samples { + let mut out = BTreeMap::new(); + for (sample_identifier, sample) in samples { let context = serde_json::to_value(&sample)?; ::tracing::info!(name, %context, "Rendering template"); - templates. $name (&sample) - .with_context(|| format!("Failed to render template {:?} with context {}", name, context))?; + let rendered = templates. $name (&sample) + .with_context(|| format!("Failed to render sample template {name:?}-{sample_identifier:?} with context {context}"))?; + out.insert(sample_identifier, rendered); } - Ok(()) + Ok(out) } )* } diff --git a/docs/api/spec.json b/docs/api/spec.json index 166436454..f1f4d30c4 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -35,6 +35,10 @@ "server_name": "example.com", "password_login_enabled": true, "password_registration_enabled": true, +<<<<<<< HEAD +======= + "password_registration_email_required": true, +>>>>>>> v1.6.0 "registration_token_required": true, "email_change_allowed": true, "displayname_change_allowed": true, @@ -50,6 +54,33 @@ } } }, +<<<<<<< HEAD +======= + "/api/admin/v1/version": { + "get": { + "tags": [ + "server" + ], + "summary": "Get the version currently running", + "operationId": "version", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Version" + }, + "example": { + "version": "v1.0.0" + } + } + } + } + } + } + }, +>>>>>>> v1.6.0 "/api/admin/v1/compat-sessions": { "get": { "tags": [ @@ -107,6 +138,17 @@ }, "style": "form" }, + { + "in": "query", + "name": "count", + "description": "Include the total number of items. Defaults to `true`.", + "schema": { + "description": "Include the total number of items. Defaults to `true`.", + "$ref": "#/components/schemas/IncludeCount", + "nullable": true + }, + "style": "form" + }, { "in": "query", "name": "filter[user]", @@ -171,6 +213,11 @@ }, "links": { "self": "/api/admin/v1/compat-sessions/01040G2081040G2081040G2081" + }, + "meta": { + "page": { + "cursor": "01040G2081040G2081040G2081" + } } }, { @@ -190,6 +237,11 @@ }, "links": { "self": "/api/admin/v1/compat-sessions/02081040G2081040G2081040G2" + }, + "meta": { + "page": { + "cursor": "02081040G2081040G2081040G2" + } } }, { @@ -209,6 +261,11 @@ }, "links": { "self": "/api/admin/v1/compat-sessions/030C1G60R30C1G60R30C1G60R3" + }, + "meta": { + "page": { + "cursor": "030C1G60R30C1G60R30C1G60R3" + } } } ], @@ -316,6 +373,98 @@ } } }, + "/api/admin/v1/compat-sessions/{id}/finish": { + "post": { + "tags": [ + "compat-session" + ], + "summary": "Finish a compatibility session", + "description": "Calling this endpoint will finish the compatibility session, preventing any further use. A job will be scheduled to sync the user's devices with the homeserver.", + "operationId": "finishCompatSession", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "Compatibility session was finished", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_CompatSession" + }, + "example": { + "data": { + "type": "compat-session", + "id": "02081040G2081040G2081040G2", + "attributes": { + "user_id": "01040G2081040G2081040G2081", + "device_id": "FFGGHHIIJJ", + "user_session_id": "0J289144GJ289144GJ289144GJ", + "redirect_uri": null, + "created_at": "1970-01-01T00:00:00Z", + "user_agent": "Mozilla/5.0", + "last_active_at": "1970-01-01T00:00:00Z", + "last_active_ip": "1.2.3.4", + "finished_at": "1970-01-01T00:00:00Z", + "human_name": null + }, + "links": { + "self": "/api/admin/v1/compat-sessions/02081040G2081040G2081040G2" + } + }, + "links": { + "self": "/api/admin/v1/compat-sessions/02081040G2081040G2081040G2/finish" + } + } + } + } + }, + "400": { + "description": "Session is already finished", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Compatibility session with ID 00000000000000000000000000 is already finished" + } + ] + } + } + } + }, + "404": { + "description": "Compatibility session was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Compatibility session with ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, "/api/admin/v1/oauth2-sessions": { "get": { "tags": [ @@ -373,6 +522,17 @@ }, "style": "form" }, + { + "in": "query", + "name": "count", + "description": "Include the total number of items. Defaults to `true`.", + "schema": { + "description": "Include the total number of items. Defaults to `true`.", + "$ref": "#/components/schemas/IncludeCount", + "nullable": true + }, + "style": "form" + }, { "in": "query", "name": "filter[user]", @@ -473,6 +633,11 @@ }, "links": { "self": "/api/admin/v1/oauth2-sessions/01040G2081040G2081040G2081" + }, + "meta": { + "page": { + "cursor": "01040G2081040G2081040G2081" + } } }, { @@ -492,6 +657,11 @@ }, "links": { "self": "/api/admin/v1/oauth2-sessions/02081040G2081040G2081040G2" + }, + "meta": { + "page": { + "cursor": "02081040G2081040G2081040G2" + } } }, { @@ -511,6 +681,11 @@ }, "links": { "self": "/api/admin/v1/oauth2-sessions/030C1G60R30C1G60R30C1G60R3" + }, + "meta": { + "page": { + "cursor": "030C1G60R30C1G60R30C1G60R3" + } } } ], @@ -635,56 +810,63 @@ } } }, - "/api/admin/v1/policy-data": { + "/api/admin/v1/oauth2-sessions/{id}/finish": { "post": { "tags": [ - "policy-data" + "oauth2-session" + ], + "summary": "Finish an OAuth 2.0 session", + "description": "Calling this endpoint will finish the OAuth 2.0 session, preventing any further use. If the session has a user associated with it, a job will be scheduled to sync the user's devices with the homeserver.", + "operationId": "finishOAuth2Session", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } ], - "summary": "Set the current policy data", - "operationId": "setPolicyData", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetPolicyDataRequest" - } - } - }, - "required": true - }, "responses": { - "201": { - "description": "Policy data was successfully set", + "200": { + "description": "OAuth 2.0 session was finished", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SingleResponse_for_PolicyData" + "$ref": "#/components/schemas/SingleResponse_for_OAuth2Session" }, "example": { "data": { - "type": "policy-data", - "id": "01040G2081040G2081040G2081", + "type": "oauth2-session", + "id": "030C1G60R30C1G60R30C1G60R3", "attributes": { "created_at": "1970-01-01T00:00:00Z", - "data": { - "hello": "world", - "foo": 42, - "bar": true - } + "finished_at": "1970-01-01T00:00:00Z", + "user_id": "040G2081040G2081040G208104", + "user_session_id": "050M2GA1850M2GA1850M2GA185", + "client_id": "060R30C1G60R30C1G60R30C1G6", + "scope": "urn:matrix:client:api:*", + "user_agent": "Mozilla/5.0", + "last_active_at": "1970-01-01T00:00:00Z", + "last_active_ip": "127.0.0.1", + "human_name": null }, "links": { - "self": "/api/admin/v1/policy-data/01040G2081040G2081040G2081" + "self": "/api/admin/v1/oauth2-sessions/030C1G60R30C1G60R30C1G60R3" } }, "links": { - "self": "/api/admin/v1/policy-data/01040G2081040G2081040G2081" + "self": "/api/admin/v1/oauth2-sessions/030C1G60R30C1G60R30C1G60R3/finish" } } } } }, "400": { - "description": "Invalid policy data", + "description": "Session is already finished", "content": { "application/json": { "schema": { @@ -693,62 +875,15 @@ "example": { "errors": [ { - "title": "Failed to instanciate policy with the provided data" - }, - { - "title": "invalid policy data" - }, - { - "title": "Failed to merge policy data objects" + "title": "OAuth 2.0 session with ID 00000000000000000000000000 is already finished" } ] } } } - } - } - } - }, - "/api/admin/v1/policy-data/latest": { - "get": { - "tags": [ - "policy-data" - ], - "summary": "Get the latest policy data", - "operationId": "getLatestPolicyData", - "responses": { - "200": { - "description": "Latest policy data was found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SingleResponse_for_PolicyData" - }, - "example": { - "data": { - "type": "policy-data", - "id": "01040G2081040G2081040G2081", - "attributes": { - "created_at": "1970-01-01T00:00:00Z", - "data": { - "hello": "world", - "foo": 42, - "bar": true - } - }, - "links": { - "self": "/api/admin/v1/policy-data/01040G2081040G2081040G2081" - } - }, - "links": { - "self": "/api/admin/v1/policy-data/01040G2081040G2081040G2081" - } - } - } - } }, "404": { - "description": "No policy data was found", + "description": "OAuth 2.0 session was not found", "content": { "application/json": { "schema": { @@ -757,7 +892,7 @@ "example": { "errors": [ { - "title": "No policy data found" + "title": "OAuth 2.0 session with ID 00000000000000000000000000 not found" } ] } @@ -767,92 +902,23 @@ } } }, - "/api/admin/v1/policy-data/{id}": { + "/api/admin/v1/personal-sessions": { "get": { "tags": [ - "policy-data" + "personal-session" ], - "summary": "Get policy data by ID", - "operationId": "getPolicyData", + "summary": "List personal sessions", + "description": "Retrieve a list of personal sessions.\nNote that by default, all sessions, including revoked ones are returned, with the oldest first.\nUse the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions.", + "operationId": "listPersonalSessions", "parameters": [ { - "in": "path", - "name": "id", - "required": true, + "in": "query", + "name": "page[before]", + "description": "Retrieve the items before the given ID", "schema": { - "title": "The ID of the resource", - "$ref": "#/components/schemas/ULID" - }, - "style": "simple" - } - ], - "responses": { - "200": { - "description": "Policy data was found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SingleResponse_for_PolicyData" - }, - "example": { - "data": { - "type": "policy-data", - "id": "01040G2081040G2081040G2081", - "attributes": { - "created_at": "1970-01-01T00:00:00Z", - "data": { - "hello": "world", - "foo": 42, - "bar": true - } - }, - "links": { - "self": "/api/admin/v1/policy-data/01040G2081040G2081040G2081" - } - }, - "links": { - "self": "/api/admin/v1/policy-data/01040G2081040G2081040G2081" - } - } - } - } - }, - "404": { - "description": "Policy data was not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "errors": [ - { - "title": "Policy data with ID 00000000000000000000000000 not found" - } - ] - } - } - } - } - } - } - }, - "/api/admin/v1/users": { - "get": { - "tags": [ - "user" - ], - "summary": "List users", - "operationId": "listUsers", - "parameters": [ - { - "in": "query", - "name": "page[before]", - "description": "Retrieve the items before the given ID", - "schema": { - "description": "Retrieve the items before the given ID", - "$ref": "#/components/schemas/ULID", - "nullable": true + "description": "Retrieve the items before the given ID", + "$ref": "#/components/schemas/ULID", + "nullable": true }, "style": "form" }, @@ -895,44 +961,104 @@ }, { "in": "query", - "name": "filter[admin]", - "description": "Retrieve users with (or without) the `admin` flag set", + "name": "count", + "description": "Include the total number of items. Defaults to `true`.", "schema": { - "description": "Retrieve users with (or without) the `admin` flag set", - "type": "boolean", + "description": "Include the total number of items. Defaults to `true`.", + "$ref": "#/components/schemas/IncludeCount", "nullable": true }, "style": "form" }, { "in": "query", - "name": "filter[legacy-guest]", - "description": "Retrieve users with (or without) the `legacy_guest` flag set", + "name": "filter[owner_user]", + "description": "Filter by owner user ID", "schema": { - "description": "Retrieve users with (or without) the `legacy_guest` flag set", - "type": "boolean", + "description": "Filter by owner user ID", + "$ref": "#/components/schemas/ULID", "nullable": true }, "style": "form" }, { "in": "query", - "name": "filter[search]", - "description": "Retrieve users where the username matches contains the given string\n\nNote that this doesn't change the ordering of the result, which are still ordered by ID.", + "name": "filter[owner_client]", + "description": "Filter by owner `OAuth2` client ID", "schema": { - "description": "Retrieve users where the username matches contains the given string\n\nNote that this doesn't change the ordering of the result, which are still ordered by ID.", - "type": "string", + "description": "Filter by owner `OAuth2` client ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[actor_user]", + "description": "Filter by actor user ID", + "schema": { + "description": "Filter by actor user ID", + "$ref": "#/components/schemas/ULID", "nullable": true }, "style": "form" }, + { + "in": "query", + "name": "filter[scope]", + "description": "Retrieve the items with the given scope", + "schema": { + "description": "Retrieve the items with the given scope", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form" + }, { "in": "query", "name": "filter[status]", - "description": "Retrieve the items with the given status\n\nDefaults to retrieve all users, including locked ones.\n\n* `active`: Only retrieve active users\n\n* `locked`: Only retrieve locked users (includes deactivated users)\n\n* `deactivated`: Only retrieve deactivated users", + "description": "Filter by session status", "schema": { - "description": "Retrieve the items with the given status\n\nDefaults to retrieve all users, including locked ones.\n\n* `active`: Only retrieve active users\n\n* `locked`: Only retrieve locked users (includes deactivated users)\n\n* `deactivated`: Only retrieve deactivated users", - "$ref": "#/components/schemas/UserStatus", + "description": "Filter by session status", + "$ref": "#/components/schemas/PersonalSessionStatus", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[expires_before]", + "description": "Filter by access token expiry date", + "schema": { + "description": "Filter by access token expiry date", + "type": "string", + "format": "date-time", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[expires_after]", + "description": "Filter by access token expiry date", + "schema": { + "description": "Filter by access token expiry date", + "type": "string", + "format": "date-time", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[expires]", + "description": "Filter by whether the access token has an expiry time", + "schema": { + "description": "Filter by whether the access token has an expiry time", + "type": "boolean", "nullable": true }, "style": "form" @@ -940,86 +1066,130 @@ ], "responses": { "200": { - "description": "Paginated response of users", + "description": "Paginated response of personal sessions", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedResponse_for_User" + "$ref": "#/components/schemas/PaginatedResponse_for_PersonalSession" }, "example": { "meta": { - "count": 42 + "count": 3 }, "data": [ { - "type": "user", - "id": "01040G2081040G2081040G2081", + "type": "personal-session", + "id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", "attributes": { - "username": "alice", - "created_at": "1970-01-01T00:00:00Z", - "locked_at": null, - "deactivated_at": null, - "admin": false, - "legacy_guest": false + "created_at": "2022-01-16T13:00:00Z", + "revoked_at": null, + "owner_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_name": "Alice's Development Token", + "scope": "openid urn:matrix:org.matrix.msc2967.client:api:*", + "last_active_at": "2022-01-16T15:30:00Z", + "last_active_ip": "192.168.1.100", + "expires_at": null }, "links": { - "self": "/api/admin/v1/users/01040G2081040G2081040G2081" + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } } }, { - "type": "user", - "id": "02081040G2081040G2081040G2", + "type": "personal-session", + "id": "01FSHN9AG0BJ6AC5HQ9X6H4RP5", "attributes": { - "username": "bob", - "created_at": "1970-01-01T00:00:00Z", - "locked_at": null, - "deactivated_at": null, - "admin": true, - "legacy_guest": false + "created_at": "2022-01-16T13:01:00Z", + "revoked_at": "2022-01-16T16:20:00Z", + "owner_user_id": "01FSHN9AG0NZAA6S4AF7CTV32F", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG0NZAA6S4AF7CTV32F", + "human_name": "Bob's Mobile App", + "scope": "openid", + "last_active_at": "2022-01-16T16:03:20Z", + "last_active_ip": "10.0.0.50", + "expires_at": null }, "links": { - "self": "/api/admin/v1/users/02081040G2081040G2081040G2" + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0BJ6AC5HQ9X6H4RP5" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0BJ6AC5HQ9X6H4RP5" + } } }, { - "type": "user", - "id": "030C1G60R30C1G60R30C1G60R3", + "type": "personal-session", + "id": "01FSHN9AG0CJ6AC5HQ9X6H4RP6", "attributes": { - "username": "charlie", - "created_at": "1970-01-01T00:00:00Z", - "locked_at": "1970-01-01T00:00:00Z", - "deactivated_at": null, - "admin": false, - "legacy_guest": true + "created_at": "2022-01-16T13:02:00Z", + "revoked_at": null, + "owner_user_id": null, + "owner_client_id": "01FSHN9AG0DJ6AC5HQ9X6H4RP7", + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_name": "CI/CD Pipeline Token", + "scope": "openid urn:mas:admin", + "last_active_at": "2022-01-16T15:46:40Z", + "last_active_ip": "203.0.113.10", + "expires_at": "2022-01-24T04:36:40Z" }, "links": { - "self": "/api/admin/v1/users/030C1G60R30C1G60R30C1G60R3" + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0CJ6AC5HQ9X6H4RP6" + }, + "meta": { + "page": { + "cursor": "01FSHN9AG0CJ6AC5HQ9X6H4RP6" + } } } ], "links": { - "self": "/api/admin/v1/users?page[first]=3", - "first": "/api/admin/v1/users?page[first]=3", - "last": "/api/admin/v1/users?page[last]=3", - "next": "/api/admin/v1/users?page[after]=030C1G60R30C1G60R30C1G60R3&page[first]=3" + "self": "/api/admin/v1/personal-sessions?page[first]=3", + "first": "/api/admin/v1/personal-sessions?page[first]=3", + "last": "/api/admin/v1/personal-sessions?page[last]=3", + "next": "/api/admin/v1/personal-sessions?page[after]=01FSHN9AG0CJ6AC5HQ9X6H4RP6&page[first]=3" } } } } + }, + "404": { + "description": "Client was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Client ID 00000000000000000000000000 not found" + } + ] + } + } + } } } }, "post": { "tags": [ - "user" + "personal-session" ], - "summary": "Create a new user", - "operationId": "createUser", + "summary": "Create a new personal session with personal access token", + "operationId": "createPersonalSession", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AddUserRequest" + "$ref": "#/components/schemas/CreatePersonalSessionRequest" } } }, @@ -1027,37 +1197,17 @@ }, "responses": { "201": { - "description": "User was created", + "description": "Personal session and personal access token were created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SingleResponse_for_User" - }, - "example": { - "data": { - "type": "user", - "id": "01040G2081040G2081040G2081", - "attributes": { - "username": "alice", - "created_at": "1970-01-01T00:00:00Z", - "locked_at": null, - "deactivated_at": null, - "admin": false, - "legacy_guest": false - }, - "links": { - "self": "/api/admin/v1/users/01040G2081040G2081040G2081" - } - }, - "links": { - "self": "/api/admin/v1/users/01040G2081040G2081040G2081" - } + "$ref": "#/components/schemas/SingleResponse_for_PersonalSession" } } } }, "400": { - "description": "Username is not valid", + "description": "Invalid scope provided", "content": { "application/json": { "schema": { @@ -1066,15 +1216,15 @@ "example": { "errors": [ { - "title": "Username is not valid" + "title": "Invalid scope" } ] } } } }, - "409": { - "description": "Username is reserved by the homeserver", + "404": { + "description": "User was not found", "content": { "application/json": { "schema": { @@ -1083,7 +1233,7 @@ "example": { "errors": [ { - "title": "Username is reserved by the homeserver" + "title": "User not found" } ] } @@ -1093,13 +1243,13 @@ } } }, - "/api/admin/v1/users/{id}": { + "/api/admin/v1/personal-sessions/{id}": { "get": { "tags": [ - "user" + "personal-session" ], - "summary": "Get a user", - "operationId": "getUser", + "summary": "Get a personal session", + "operationId": "getPersonalSession", "parameters": [ { "in": "path", @@ -1114,37 +1264,41 @@ ], "responses": { "200": { - "description": "User was found", + "description": "Personal session details", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SingleResponse_for_User" + "$ref": "#/components/schemas/SingleResponse_for_PersonalSession" }, "example": { "data": { - "type": "user", - "id": "01040G2081040G2081040G2081", + "type": "personal-session", + "id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", "attributes": { - "username": "alice", - "created_at": "1970-01-01T00:00:00Z", - "locked_at": null, - "deactivated_at": null, - "admin": false, - "legacy_guest": false + "created_at": "2022-01-16T13:00:00Z", + "revoked_at": null, + "owner_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_name": "Alice's Development Token", + "scope": "openid urn:matrix:org.matrix.msc2967.client:api:*", + "last_active_at": "2022-01-16T15:30:00Z", + "last_active_ip": "192.168.1.100", + "expires_at": null }, "links": { - "self": "/api/admin/v1/users/01040G2081040G2081040G2081" + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" } }, "links": { - "self": "/api/admin/v1/users/01040G2081040G2081040G2081" + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" } } } } }, "404": { - "description": "User was not found", + "description": "Personal session not found", "content": { "application/json": { "schema": { @@ -1153,7 +1307,7 @@ "example": { "errors": [ { - "title": "User ID 00000000000000000000000000 not found" + "title": "Personal session not found" } ] } @@ -1163,13 +1317,13 @@ } } }, - "/api/admin/v1/users/{id}/set-password": { + "/api/admin/v1/personal-sessions/{id}/revoke": { "post": { "tags": [ - "user" + "personal-session" ], - "summary": "Set the password for a user", - "operationId": "setUserPassword", + "summary": "Revoke a personal session", + "operationId": "revokePersonalSession", "parameters": [ { "in": "path", @@ -1182,39 +1336,43 @@ "style": "simple" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetUserPasswordRequest" - } - } - }, - "required": true - }, "responses": { - "204": { - "description": "Password was set" - }, - "400": { - "description": "Password is too weak", + "200": { + "description": "Personal session was revoked", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/SingleResponse_for_PersonalSession" }, "example": { - "errors": [ - { - "title": "Password is too weak" + "data": { + "type": "personal-session", + "id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "attributes": { + "created_at": "2022-01-16T13:00:00Z", + "revoked_at": null, + "owner_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "owner_client_id": null, + "actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_name": "Alice's Development Token", + "scope": "openid urn:matrix:org.matrix.msc2967.client:api:*", + "last_active_at": "2022-01-16T15:30:00Z", + "last_active_ip": "192.168.1.100", + "expires_at": null + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" } - ] + }, + "links": { + "self": "/api/admin/v1/personal-sessions/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } } } } }, - "403": { - "description": "Password auth is disabled in the server configuration", + "404": { + "description": "Personal session not found", "content": { "application/json": { "schema": { @@ -1223,15 +1381,15 @@ "example": { "errors": [ { - "title": "Password auth is disabled" + "title": "Personal session with ID 00000000000000000000000000 not found" } ] } } } }, - "404": { - "description": "User was not found", + "409": { + "description": "Personal session already revoked", "content": { "application/json": { "schema": { @@ -1240,7 +1398,7 @@ "example": { "errors": [ { - "title": "User ID 00000000000000000000000000 not found" + "title": "Personal session with ID 00000000000000000000000000 is already revoked" } ] } @@ -1250,85 +1408,13 @@ } } }, - "/api/admin/v1/users/by-username/{username}": { - "get": { + "/api/admin/v1/personal-sessions/{id}/regenerate": { + "post": { "tags": [ - "user" + "personal-session" ], - "summary": "Get a user by its username (localpart)", - "operationId": "getUserByUsername", - "parameters": [ - { - "in": "path", - "name": "username", - "description": "The username (localpart) of the user to get", - "required": true, - "schema": { - "description": "The username (localpart) of the user to get", - "type": "string" - }, - "style": "simple" - } - ], - "responses": { - "200": { - "description": "User was found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SingleResponse_for_User" - }, - "example": { - "data": { - "type": "user", - "id": "01040G2081040G2081040G2081", - "attributes": { - "username": "alice", - "created_at": "1970-01-01T00:00:00Z", - "locked_at": null, - "deactivated_at": null, - "admin": false, - "legacy_guest": false - }, - "links": { - "self": "/api/admin/v1/users/01040G2081040G2081040G2081" - } - }, - "links": { - "self": "/api/admin/v1/users/by-username/alice" - } - } - } - } - }, - "404": { - "description": "User was not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "errors": [ - { - "title": "User with username \"alice\" not found" - } - ] - } - } - } - } - } - } - }, - "/api/admin/v1/users/{id}/set-admin": { - "post": { - "tags": [ - "user" - ], - "summary": "Set whether a user can request admin", - "description": "Calling this endpoint will not have any effect on existing sessions, meaning that their existing sessions will keep admin access if they were granted it.", - "operationId": "userSetAdmin", + "summary": "Regenerate a personal session by replacing its personal access token", + "operationId": "regeneratePersonalSession", "parameters": [ { "in": "path", @@ -1345,45 +1431,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserSetAdminRequest" + "$ref": "#/components/schemas/RegeneratePersonalSessionRequest" } } }, "required": true }, "responses": { - "200": { - "description": "User had admin privileges set", + "201": { + "description": "Personal session was regenerated and a personal access token was created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SingleResponse_for_User" - }, - "example": { - "data": { - "type": "user", - "id": "02081040G2081040G2081040G2", - "attributes": { - "username": "bob", - "created_at": "1970-01-01T00:00:00Z", - "locked_at": null, - "deactivated_at": null, - "admin": true, - "legacy_guest": false - }, - "links": { - "self": "/api/admin/v1/users/02081040G2081040G2081040G2" - } - }, - "links": { - "self": "/api/admin/v1/users/02081040G2081040G2081040G2/set-admin" - } + "$ref": "#/components/schemas/SingleResponse_for_PersonalSession" } } } }, "404": { - "description": "User ID not found", + "description": "User was not found", "content": { "application/json": { "schema": { @@ -1392,7 +1458,7 @@ "example": { "errors": [ { - "title": "User ID 00000000000000000000000000 not found" + "title": "User not found" } ] } @@ -1402,68 +1468,56 @@ } } }, - "/api/admin/v1/users/{id}/deactivate": { + "/api/admin/v1/policy-data": { "post": { "tags": [ - "user" - ], - "summary": "Deactivate a user", - "description": "Calling this endpoint will deactivate the user, preventing them from doing any action.\nThis invalidates any existing session, and will ask the homeserver to make them leave all rooms.", - "operationId": "deactivateUser", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "title": "The ID of the resource", - "$ref": "#/components/schemas/ULID" - }, - "style": "simple" - } + "policy-data" ], + "summary": "Set the current policy data", + "operationId": "setPolicyData", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeactivateUserRequest" + "$ref": "#/components/schemas/SetPolicyDataRequest" } } - } + }, + "required": true }, "responses": { - "200": { - "description": "User was deactivated", + "201": { + "description": "Policy data was successfully set", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SingleResponse_for_User" + "$ref": "#/components/schemas/SingleResponse_for_PolicyData" }, "example": { "data": { - "type": "user", - "id": "030C1G60R30C1G60R30C1G60R3", + "type": "policy-data", + "id": "01040G2081040G2081040G2081", "attributes": { - "username": "charlie", "created_at": "1970-01-01T00:00:00Z", - "locked_at": "1970-01-01T00:00:00Z", - "deactivated_at": null, - "admin": false, - "legacy_guest": true + "data": { + "hello": "world", + "foo": 42, + "bar": true + } }, "links": { - "self": "/api/admin/v1/users/030C1G60R30C1G60R30C1G60R3" + "self": "/api/admin/v1/policy-data/01040G2081040G2081040G2081" } }, "links": { - "self": "/api/admin/v1/users/030C1G60R30C1G60R30C1G60R3/deactivate" + "self": "/api/admin/v1/policy-data/01040G2081040G2081040G2081" } } } } }, - "404": { - "description": "User ID not found", + "400": { + "description": "Invalid policy data", "content": { "application/json": { "schema": { @@ -1472,7 +1526,13 @@ "example": { "errors": [ { - "title": "User ID 00000000000000000000000000 not found" + "title": "Failed to instanciate policy with the provided data" + }, + { + "title": "invalid policy data" + }, + { + "title": "Failed to merge policy data objects" } ] } @@ -1482,59 +1542,46 @@ } } }, - "/api/admin/v1/users/{id}/reactivate": { - "post": { + "/api/admin/v1/policy-data/latest": { + "get": { "tags": [ - "user" - ], - "summary": "Reactivate a user", - "description": "Calling this endpoint will reactivate a deactivated user.\nThis DOES NOT unlock a locked user, which is still prevented from doing any action until it is explicitly unlocked.", - "operationId": "reactivateUser", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "title": "The ID of the resource", - "$ref": "#/components/schemas/ULID" - }, - "style": "simple" - } + "policy-data" ], + "summary": "Get the latest policy data", + "operationId": "getLatestPolicyData", "responses": { "200": { - "description": "User was reactivated", + "description": "Latest policy data was found", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SingleResponse_for_User" + "$ref": "#/components/schemas/SingleResponse_for_PolicyData" }, "example": { "data": { - "type": "user", + "type": "policy-data", "id": "01040G2081040G2081040G2081", "attributes": { - "username": "alice", "created_at": "1970-01-01T00:00:00Z", - "locked_at": null, - "deactivated_at": null, - "admin": false, - "legacy_guest": false + "data": { + "hello": "world", + "foo": 42, + "bar": true + } }, "links": { - "self": "/api/admin/v1/users/01040G2081040G2081040G2081" + "self": "/api/admin/v1/policy-data/01040G2081040G2081040G2081" } }, "links": { - "self": "/api/admin/v1/users/01040G2081040G2081040G2081/reactivate" + "self": "/api/admin/v1/policy-data/01040G2081040G2081040G2081" } } } } }, "404": { - "description": "User ID not found", + "description": "No policy data was found", "content": { "application/json": { "schema": { @@ -1543,7 +1590,7 @@ "example": { "errors": [ { - "title": "User ID 00000000000000000000000000 not found" + "title": "No policy data found" } ] } @@ -1553,14 +1600,13 @@ } } }, - "/api/admin/v1/users/{id}/lock": { - "post": { + "/api/admin/v1/policy-data/{id}": { + "get": { "tags": [ - "user" + "policy-data" ], - "summary": "Lock a user", - "description": "Calling this endpoint will lock the user, preventing them from doing any action.\nThis DOES NOT invalidate any existing session, meaning that all their existing sessions will work again as soon as they get unlocked.", - "operationId": "lockUser", + "summary": "Get policy data by ID", + "operationId": "getPolicyData", "parameters": [ { "in": "path", @@ -1575,37 +1621,37 @@ ], "responses": { "200": { - "description": "User was locked", + "description": "Policy data was found", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SingleResponse_for_User" + "$ref": "#/components/schemas/SingleResponse_for_PolicyData" }, "example": { "data": { - "type": "user", - "id": "030C1G60R30C1G60R30C1G60R3", + "type": "policy-data", + "id": "01040G2081040G2081040G2081", "attributes": { - "username": "charlie", "created_at": "1970-01-01T00:00:00Z", - "locked_at": "1970-01-01T00:00:00Z", - "deactivated_at": null, - "admin": false, - "legacy_guest": true + "data": { + "hello": "world", + "foo": 42, + "bar": true + } }, "links": { - "self": "/api/admin/v1/users/030C1G60R30C1G60R30C1G60R3" + "self": "/api/admin/v1/policy-data/01040G2081040G2081040G2081" } }, "links": { - "self": "/api/admin/v1/users/030C1G60R30C1G60R30C1G60R3/lock" + "self": "/api/admin/v1/policy-data/01040G2081040G2081040G2081" } } } } }, "404": { - "description": "User ID not found", + "description": "Policy data was not found", "content": { "application/json": { "schema": { @@ -1614,7 +1660,7 @@ "example": { "errors": [ { - "title": "User ID 00000000000000000000000000 not found" + "title": "Policy data with ID 00000000000000000000000000 not found" } ] } @@ -1624,14 +1670,827 @@ } } }, - "/api/admin/v1/users/{id}/unlock": { - "post": { + "/api/admin/v1/users": { + "get": { "tags": [ "user" ], - "summary": "Unlock a user", - "description": "Calling this endpoint will lift restrictions on user actions that had imposed by locking.\nThis DOES NOT reactivate a deactivated user, which will remain unavailable until it is explicitly reactivated.", - "operationId": "unlockUser", + "summary": "List users", + "operationId": "listUsers", + "parameters": [ + { + "in": "query", + "name": "page[before]", + "description": "Retrieve the items before the given ID", + "schema": { + "description": "Retrieve the items before the given ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[after]", + "description": "Retrieve the items after the given ID", + "schema": { + "description": "Retrieve the items after the given ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[first]", + "description": "Retrieve the first N items", + "schema": { + "description": "Retrieve the first N items", + "type": "integer", + "format": "uint", + "minimum": 1.0, + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[last]", + "description": "Retrieve the last N items", + "schema": { + "description": "Retrieve the last N items", + "type": "integer", + "format": "uint", + "minimum": 1.0, + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "count", + "description": "Include the total number of items. Defaults to `true`.", + "schema": { + "description": "Include the total number of items. Defaults to `true`.", + "$ref": "#/components/schemas/IncludeCount", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[admin]", + "description": "Retrieve users with (or without) the `admin` flag set", + "schema": { + "description": "Retrieve users with (or without) the `admin` flag set", + "type": "boolean", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[legacy-guest]", + "description": "Retrieve users with (or without) the `legacy_guest` flag set", + "schema": { + "description": "Retrieve users with (or without) the `legacy_guest` flag set", + "type": "boolean", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[search]", + "description": "Retrieve users where the username matches contains the given string\n\nNote that this doesn't change the ordering of the result, which are still ordered by ID.", + "schema": { + "description": "Retrieve users where the username matches contains the given string\n\nNote that this doesn't change the ordering of the result, which are still ordered by ID.", + "type": "string", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[status]", + "description": "Retrieve the items with the given status\n\nDefaults to retrieve all users, including locked ones.\n\n* `active`: Only retrieve active users\n\n* `locked`: Only retrieve locked users (includes deactivated users)\n\n* `deactivated`: Only retrieve deactivated users", + "schema": { + "description": "Retrieve the items with the given status\n\nDefaults to retrieve all users, including locked ones.\n\n* `active`: Only retrieve active users\n\n* `locked`: Only retrieve locked users (includes deactivated users)\n\n* `deactivated`: Only retrieve deactivated users", + "$ref": "#/components/schemas/UserStatus", + "nullable": true + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "Paginated response of users", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse_for_User" + }, + "example": { + "meta": { + "count": 42 + }, + "data": [ + { + "type": "user", + "id": "01040G2081040G2081040G2081", + "attributes": { + "username": "alice", + "created_at": "1970-01-01T00:00:00Z", + "locked_at": null, + "deactivated_at": null, + "admin": false, + "legacy_guest": false + }, + "links": { + "self": "/api/admin/v1/users/01040G2081040G2081040G2081" + }, + "meta": { + "page": { + "cursor": "01040G2081040G2081040G2081" + } + } + }, + { + "type": "user", + "id": "02081040G2081040G2081040G2", + "attributes": { + "username": "bob", + "created_at": "1970-01-01T00:00:00Z", + "locked_at": null, + "deactivated_at": null, + "admin": true, + "legacy_guest": false + }, + "links": { + "self": "/api/admin/v1/users/02081040G2081040G2081040G2" + }, + "meta": { + "page": { + "cursor": "02081040G2081040G2081040G2" + } + } + }, + { + "type": "user", + "id": "030C1G60R30C1G60R30C1G60R3", + "attributes": { + "username": "charlie", + "created_at": "1970-01-01T00:00:00Z", + "locked_at": "1970-01-01T00:00:00Z", + "deactivated_at": null, + "admin": false, + "legacy_guest": true + }, + "links": { + "self": "/api/admin/v1/users/030C1G60R30C1G60R30C1G60R3" + }, + "meta": { + "page": { + "cursor": "030C1G60R30C1G60R30C1G60R3" + } + } + } + ], + "links": { + "self": "/api/admin/v1/users?page[first]=3", + "first": "/api/admin/v1/users?page[first]=3", + "last": "/api/admin/v1/users?page[last]=3", + "next": "/api/admin/v1/users?page[after]=030C1G60R30C1G60R30C1G60R3&page[first]=3" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "user" + ], + "summary": "Create a new user", + "operationId": "createUser", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddUserRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "User was created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_User" + }, + "example": { + "data": { + "type": "user", + "id": "01040G2081040G2081040G2081", + "attributes": { + "username": "alice", + "created_at": "1970-01-01T00:00:00Z", + "locked_at": null, + "deactivated_at": null, + "admin": false, + "legacy_guest": false + }, + "links": { + "self": "/api/admin/v1/users/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/users/01040G2081040G2081040G2081" + } + } + } + } + }, + "400": { + "description": "Username is not valid", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Username is not valid" + } + ] + } + } + } + }, + "409": { + "description": "Username is reserved by the homeserver", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Username is reserved by the homeserver" + } + ] + } + } + } + } + } + } + }, + "/api/admin/v1/users/{id}": { + "get": { + "tags": [ + "user" + ], + "summary": "Get a user", + "operationId": "getUser", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "User was found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_User" + }, + "example": { + "data": { + "type": "user", + "id": "01040G2081040G2081040G2081", + "attributes": { + "username": "alice", + "created_at": "1970-01-01T00:00:00Z", + "locked_at": null, + "deactivated_at": null, + "admin": false, + "legacy_guest": false + }, + "links": { + "self": "/api/admin/v1/users/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/users/01040G2081040G2081040G2081" + } + } + } + } + }, + "404": { + "description": "User was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, + "/api/admin/v1/users/{id}/set-password": { + "post": { + "tags": [ + "user" + ], + "summary": "Set the password for a user", + "operationId": "setUserPassword", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetUserPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Password was set" + }, + "400": { + "description": "Password is too weak", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Password is too weak" + } + ] + } + } + } + }, + "403": { + "description": "Password auth is disabled in the server configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Password auth is disabled" + } + ] + } + } + } + }, + "404": { + "description": "User was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, + "/api/admin/v1/users/by-username/{username}": { + "get": { + "tags": [ + "user" + ], + "summary": "Get a user by its username (localpart)", + "operationId": "getUserByUsername", + "parameters": [ + { + "in": "path", + "name": "username", + "description": "The username (localpart) of the user to get", + "required": true, + "schema": { + "description": "The username (localpart) of the user to get", + "type": "string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "User was found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_User" + }, + "example": { + "data": { + "type": "user", + "id": "01040G2081040G2081040G2081", + "attributes": { + "username": "alice", + "created_at": "1970-01-01T00:00:00Z", + "locked_at": null, + "deactivated_at": null, + "admin": false, + "legacy_guest": false + }, + "links": { + "self": "/api/admin/v1/users/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/users/by-username/alice" + } + } + } + } + }, + "404": { + "description": "User was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User with username \"alice\" not found" + } + ] + } + } + } + } + } + } + }, + "/api/admin/v1/users/{id}/set-admin": { + "post": { + "tags": [ + "user" + ], + "summary": "Set whether a user can request admin", + "description": "Calling this endpoint will not have any effect on existing sessions, meaning that their existing sessions will keep admin access if they were granted it.", + "operationId": "userSetAdmin", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSetAdminRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "User had admin privileges set", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_User" + }, + "example": { + "data": { + "type": "user", + "id": "02081040G2081040G2081040G2", + "attributes": { + "username": "bob", + "created_at": "1970-01-01T00:00:00Z", + "locked_at": null, + "deactivated_at": null, + "admin": true, + "legacy_guest": false + }, + "links": { + "self": "/api/admin/v1/users/02081040G2081040G2081040G2" + } + }, + "links": { + "self": "/api/admin/v1/users/02081040G2081040G2081040G2/set-admin" + } + } + } + } + }, + "404": { + "description": "User ID not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, + "/api/admin/v1/users/{id}/deactivate": { + "post": { + "tags": [ + "user" + ], + "summary": "Deactivate a user", + "description": "Calling this endpoint will deactivate the user, preventing them from doing any action.\nThis invalidates any existing session, and will ask the homeserver to make them leave all rooms.", + "operationId": "deactivateUser", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeactivateUserRequest" + } + } + } + }, + "responses": { + "200": { + "description": "User was deactivated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_User" + }, + "example": { + "data": { + "type": "user", + "id": "030C1G60R30C1G60R30C1G60R3", + "attributes": { + "username": "charlie", + "created_at": "1970-01-01T00:00:00Z", + "locked_at": "1970-01-01T00:00:00Z", + "deactivated_at": null, + "admin": false, + "legacy_guest": true + }, + "links": { + "self": "/api/admin/v1/users/030C1G60R30C1G60R30C1G60R3" + } + }, + "links": { + "self": "/api/admin/v1/users/030C1G60R30C1G60R30C1G60R3/deactivate" + } + } + } + } + }, + "404": { + "description": "User ID not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, + "/api/admin/v1/users/{id}/reactivate": { + "post": { + "tags": [ + "user" + ], + "summary": "Reactivate a user", + "description": "Calling this endpoint will reactivate a deactivated user.\nThis DOES NOT unlock a locked user, which is still prevented from doing any action until it is explicitly unlocked.", + "operationId": "reactivateUser", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "User was reactivated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_User" + }, + "example": { + "data": { + "type": "user", + "id": "01040G2081040G2081040G2081", + "attributes": { + "username": "alice", + "created_at": "1970-01-01T00:00:00Z", + "locked_at": null, + "deactivated_at": null, + "admin": false, + "legacy_guest": false + }, + "links": { + "self": "/api/admin/v1/users/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/users/01040G2081040G2081040G2081/reactivate" + } + } + } + } + }, + "404": { + "description": "User ID not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, + "/api/admin/v1/users/{id}/lock": { + "post": { + "tags": [ + "user" + ], + "summary": "Lock a user", + "description": "Calling this endpoint will lock the user, preventing them from doing any action.\nThis DOES NOT invalidate any existing session, meaning that all their existing sessions will work again as soon as they get unlocked.", + "operationId": "lockUser", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "User was locked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_User" + }, + "example": { + "data": { + "type": "user", + "id": "030C1G60R30C1G60R30C1G60R3", + "attributes": { + "username": "charlie", + "created_at": "1970-01-01T00:00:00Z", + "locked_at": "1970-01-01T00:00:00Z", + "deactivated_at": null, + "admin": false, + "legacy_guest": true + }, + "links": { + "self": "/api/admin/v1/users/030C1G60R30C1G60R30C1G60R3" + } + }, + "links": { + "self": "/api/admin/v1/users/030C1G60R30C1G60R30C1G60R3/lock" + } + } + } + } + }, + "404": { + "description": "User ID not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, + "/api/admin/v1/users/{id}/unlock": { + "post": { + "tags": [ + "user" + ], + "summary": "Unlock a user", + "description": "Calling this endpoint will lift restrictions on user actions that had imposed by locking.\nThis DOES NOT reactivate a deactivated user, which will remain unavailable until it is explicitly reactivated.", + "operationId": "unlockUser", "parameters": [ { "in": "path", @@ -1752,6 +2611,17 @@ }, "style": "form" }, + { + "in": "query", + "name": "count", + "description": "Include the total number of items. Defaults to `true`.", + "schema": { + "description": "Include the total number of items. Defaults to `true`.", + "$ref": "#/components/schemas/IncludeCount", + "nullable": true + }, + "style": "form" + }, { "in": "query", "name": "filter[user]", @@ -1798,6 +2668,11 @@ }, "links": { "self": "/api/admin/v1/user-emails/01040G2081040G2081040G2081" + }, + "meta": { + "page": { + "cursor": "01040G2081040G2081040G2081" + } } } ], @@ -2097,6 +2972,17 @@ }, "style": "form" }, + { + "in": "query", + "name": "count", + "description": "Include the total number of items. Defaults to `true`.", + "schema": { + "description": "Include the total number of items. Defaults to `true`.", + "$ref": "#/components/schemas/IncludeCount", + "nullable": true + }, + "style": "form" + }, { "in": "query", "name": "filter[user]", @@ -2146,6 +3032,11 @@ }, "links": { "self": "/api/admin/v1/user-sessions/01040G2081040G2081040G2081" + }, + "meta": { + "page": { + "cursor": "01040G2081040G2081040G2081" + } } }, { @@ -2161,6 +3052,11 @@ }, "links": { "self": "/api/admin/v1/user-sessions/02081040G2081040G2081040G2" + }, + "meta": { + "page": { + "cursor": "02081040G2081040G2081040G2" + } } }, { @@ -2176,6 +3072,11 @@ }, "links": { "self": "/api/admin/v1/user-sessions/030C1G60R30C1G60R30C1G60R3" + }, + "meta": { + "page": { + "cursor": "030C1G60R30C1G60R30C1G60R3" + } } } ], @@ -2279,6 +3180,94 @@ } } }, + "/api/admin/v1/user-sessions/{id}/finish": { + "post": { + "tags": [ + "user-session" + ], + "summary": "Finish a user session", + "description": "Calling this endpoint will finish the user session, preventing any further use.", + "operationId": "finishUserSession", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "User session was finished", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_UserSession" + }, + "example": { + "data": { + "type": "user-session", + "id": "030C1G60R30C1G60R30C1G60R3", + "attributes": { + "created_at": "1970-01-01T00:00:00Z", + "finished_at": "1970-01-01T00:00:00Z", + "user_id": "040G2081040G2081040G208104", + "user_agent": "Mozilla/5.0", + "last_active_at": "1970-01-01T00:00:00Z", + "last_active_ip": "127.0.0.1" + }, + "links": { + "self": "/api/admin/v1/user-sessions/030C1G60R30C1G60R30C1G60R3" + } + }, + "links": { + "self": "/api/admin/v1/user-sessions/030C1G60R30C1G60R30C1G60R3/finish" + } + } + } + } + }, + "400": { + "description": "Session is already finished", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User session with ID 00000000000000000000000000 is already finished" + } + ] + } + } + } + }, + "404": { + "description": "User session was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User session with ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, "/api/admin/v1/user-registration-tokens": { "get": { "tags": [ @@ -2335,6 +3324,17 @@ }, "style": "form" }, + { + "in": "query", + "name": "count", + "description": "Include the total number of items. Defaults to `true`.", + "schema": { + "description": "Include the total number of items. Defaults to `true`.", + "$ref": "#/components/schemas/IncludeCount", + "nullable": true + }, + "style": "form" + }, { "in": "query", "name": "filter[used]", @@ -2408,6 +3408,11 @@ }, "links": { "self": "/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081" + }, + "meta": { + "page": { + "cursor": "01040G2081040G2081040G2081" + } } }, { @@ -2425,6 +3430,11 @@ }, "links": { "self": "/api/admin/v1/user-registration-tokens/02081040G2081040G2081040G2" + }, + "meta": { + "page": { + "cursor": "02081040G2081040G2081040G2" + } } } ], @@ -2882,6 +3892,17 @@ }, "style": "form" }, + { + "in": "query", + "name": "count", + "description": "Include the total number of items. Defaults to `true`.", + "schema": { + "description": "Include the total number of items. Defaults to `true`.", + "$ref": "#/components/schemas/IncludeCount", + "nullable": true + }, + "style": "form" + }, { "in": "query", "name": "filter[user]", @@ -2941,6 +3962,11 @@ }, "links": { "self": "/api/admin/v1/upstream-oauth-links/01040G2081040G2081040G2081" + }, + "meta": { + "page": { + "cursor": "01040G2081040G2081040G2081" + } } }, { @@ -2955,6 +3981,11 @@ }, "links": { "self": "/api/admin/v1/upstream-oauth-links/02081040G2081040G2081040G2" + }, + "meta": { + "page": { + "cursor": "02081040G2081040G2081040G2" + } } }, { @@ -2969,6 +4000,11 @@ }, "links": { "self": "/api/admin/v1/upstream-oauth-links/030C1G60R30C1G60R30C1G60R3" + }, + "meta": { + "page": { + "cursor": "030C1G60R30C1G60R30C1G60R3" + } } } ], @@ -3281,6 +4317,20 @@ }, { "in": "query", +<<<<<<< HEAD +======= + "name": "count", + "description": "Include the total number of items. Defaults to `true`.", + "schema": { + "description": "Include the total number of items. Defaults to `true`.", + "$ref": "#/components/schemas/IncludeCount", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", +>>>>>>> v1.6.0 "name": "filter[enabled]", "description": "Retrieve providers that are (or are not) enabled", "schema": { @@ -3316,6 +4366,14 @@ }, "links": { "self": "/api/admin/v1/upstream-oauth-providers/01040G2081040G2081040G2081" +<<<<<<< HEAD +======= + }, + "meta": { + "page": { + "cursor": "01040G2081040G2081040G2081" + } +>>>>>>> v1.6.0 } }, { @@ -3330,6 +4388,14 @@ }, "links": { "self": "/api/admin/v1/upstream-oauth-providers/02081040G2081040G2081040G2" +<<<<<<< HEAD +======= + }, + "meta": { + "page": { + "cursor": "02081040G2081040G2081040G2" + } +>>>>>>> v1.6.0 } }, { @@ -3344,6 +4410,14 @@ }, "links": { "self": "/api/admin/v1/upstream-oauth-providers/030C1G60R30C1G60R30C1G60R3" +<<<<<<< HEAD +======= + }, + "meta": { + "page": { + "cursor": "030C1G60R30C1G60R30C1G60R3" + } +>>>>>>> v1.6.0 } } ], @@ -3359,6 +4433,71 @@ } } } +<<<<<<< HEAD +======= + }, + "/api/admin/v1/upstream-oauth-providers/{id}": { + "get": { + "tags": [ + "upstream-oauth-provider" + ], + "summary": "Get upstream OAuth provider", + "operationId": "getUpstreamOAuthProvider", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "The upstream OAuth provider", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_UpstreamOAuthProvider" + }, + "example": { + "data": { + "type": "upstream-oauth-provider", + "id": "01040G2081040G2081040G2081", + "attributes": { + "issuer": "https://accounts.google.com", + "human_name": "Google", + "brand_name": "google", + "created_at": "1970-01-01T00:00:00Z", + "disabled_at": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-providers/01040G2081040G2081040G2081" + } + } + } + } + }, + "404": { + "description": "Provider not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } +>>>>>>> v1.6.0 } }, "components": { @@ -3401,6 +4540,10 @@ "minimum_password_complexity", "password_change_allowed", "password_login_enabled", +<<<<<<< HEAD +======= + "password_registration_email_required", +>>>>>>> v1.6.0 "password_registration_enabled", "registration_token_required", "server_name" @@ -3418,6 +4561,13 @@ "description": "Whether password registration is enabled.", "type": "boolean" }, +<<<<<<< HEAD +======= + "password_registration_email_required": { + "description": "Whether a valid email address is required for password registrations.", + "type": "boolean" + }, +>>>>>>> v1.6.0 "registration_token_required": { "description": "Whether registration tokens are required for password registrations.", "type": "boolean" @@ -3455,6 +4605,21 @@ } } }, +<<<<<<< HEAD +======= + "Version": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "description": "The semver version of the app", + "type": "string" + } + } + }, +>>>>>>> v1.6.0 "PaginationParams": { "type": "object", "properties": { @@ -3481,6 +4646,11 @@ "format": "uint", "minimum": 1.0, "nullable": true + }, + "count": { + "description": "Include the total number of items. Defaults to `true`.", + "$ref": "#/components/schemas/IncludeCount", + "nullable": true } } }, @@ -3494,6 +4664,31 @@ "type": "string", "pattern": "^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$" }, + "IncludeCount": { + "oneOf": [ + { + "description": "Include the total number of items (default)", + "type": "string", + "enum": [ + "true" + ] + }, + { + "description": "Do not include the total number of items", + "type": "string", + "enum": [ + "false" + ] + }, + { + "description": "Only include the total number of items, skip the items themselves", + "type": "string", + "enum": [ + "only" + ] + } + ] + }, "CompatSessionFilter": { "type": "object", "properties": { @@ -3525,21 +4720,21 @@ "description": "A top-level response with a page of resources", "type": "object", "required": [ - "data", - "links", - "meta" + "links" ], "properties": { "meta": { "description": "Response metadata", - "$ref": "#/components/schemas/PaginationMeta" + "$ref": "#/components/schemas/PaginationMeta", + "nullable": true }, "data": { "description": "The list of resources", "type": "array", "items": { "$ref": "#/components/schemas/SingleResource_for_CompatSession" - } + }, + "nullable": true }, "links": { "description": "Related links", @@ -3549,15 +4744,13 @@ }, "PaginationMeta": { "type": "object", - "required": [ - "count" - ], "properties": { "count": { "description": "The total number of results", "type": "integer", "format": "uint", - "minimum": 0.0 + "minimum": 0.0, + "nullable": true } } }, @@ -3586,6 +4779,11 @@ "links": { "description": "Related links", "$ref": "#/components/schemas/SelfLinks" + }, + "meta": { + "description": "Metadata about the resource", + "$ref": "#/components/schemas/SingleResourceMeta", + "nullable": true } } }, @@ -3674,12 +4872,34 @@ } } }, + "SingleResourceMeta": { + "description": "Metadata associated with a resource", + "type": "object", + "properties": { + "page": { + "description": "Information about the pagination of the resource", + "$ref": "#/components/schemas/SingleResourceMetaPage", + "nullable": true + } + } + }, + "SingleResourceMetaPage": { + "description": "Pagination metadata for a resource", + "type": "object", + "required": [ + "cursor" + ], + "properties": { + "cursor": { + "description": "The cursor of this resource in the paginated result", + "type": "string" + } + } + }, "PaginationLinks": { "description": "Related links", "type": "object", "required": [ - "first", - "last", "self" ], "properties": { @@ -3689,66 +4909,257 @@ }, "first": { "description": "The link to the first page of results", - "type": "string" + "type": "string", + "nullable": true }, "last": { "description": "The link to the last page of results", + "type": "string", + "nullable": true + }, + "next": { + "description": "The link to the next page of results\n\nOnly present if there is a next page", + "type": "string", + "nullable": true + }, + "prev": { + "description": "The link to the previous page of results\n\nOnly present if there is a previous page", + "type": "string", + "nullable": true + } + } + }, + "ErrorResponse": { + "description": "A top-level response with a list of errors", + "type": "object", + "required": [ + "errors" + ], + "properties": { + "errors": { + "description": "The list of errors", + "type": "array", + "items": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "Error": { + "description": "A single error", + "type": "object", + "required": [ + "title" + ], + "properties": { + "title": { + "description": "A human-readable title for the error", + "type": "string" + } + } + }, + "UlidInPath": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + } + } + }, + "SingleResponse_for_CompatSession": { + "description": "A top-level response with a single resource", + "type": "object", + "required": [ + "data", + "links" + ], + "properties": { + "data": { + "$ref": "#/components/schemas/SingleResource_for_CompatSession" + }, + "links": { + "$ref": "#/components/schemas/SelfLinks" + } + } + }, + "OAuth2SessionFilter": { + "type": "object", + "properties": { + "filter[user]": { + "description": "Retrieve the items for the given user", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "filter[client]": { + "description": "Retrieve the items for the given client", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "filter[client-kind]": { + "description": "Retrieve the items only for a specific client kind", + "$ref": "#/components/schemas/OAuth2ClientKind", + "nullable": true + }, + "filter[user-session]": { + "description": "Retrieve the items started from the given browser session", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "filter[scope]": { + "description": "Retrieve the items with the given scope", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "filter[status]": { + "description": "Retrieve the items with the given status\n\nDefaults to retrieve all sessions, including finished ones.\n\n* `active`: Only retrieve active sessions\n\n* `finished`: Only retrieve finished sessions", + "$ref": "#/components/schemas/OAuth2SessionStatus", + "nullable": true + } + } + }, + "OAuth2ClientKind": { + "type": "string", + "enum": [ + "dynamic", + "static" + ] + }, + "OAuth2SessionStatus": { + "type": "string", + "enum": [ + "active", + "finished" + ] + }, + "PaginatedResponse_for_OAuth2Session": { + "description": "A top-level response with a page of resources", + "type": "object", + "required": [ + "links" + ], + "properties": { + "meta": { + "description": "Response metadata", + "$ref": "#/components/schemas/PaginationMeta", + "nullable": true + }, + "data": { + "description": "The list of resources", + "type": "array", + "items": { + "$ref": "#/components/schemas/SingleResource_for_OAuth2Session" + }, + "nullable": true + }, + "links": { + "description": "Related links", + "$ref": "#/components/schemas/PaginationLinks" + } + } + }, + "SingleResource_for_OAuth2Session": { + "description": "A single resource, with its type, ID, attributes and related links", + "type": "object", + "required": [ + "attributes", + "id", + "links", + "type" + ], + "properties": { + "type": { + "description": "The type of the resource", + "type": "string" + }, + "id": { + "description": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "attributes": { + "description": "The attributes of the resource", + "$ref": "#/components/schemas/OAuth2Session" + }, + "links": { + "description": "Related links", + "$ref": "#/components/schemas/SelfLinks" + }, + "meta": { + "description": "Metadata about the resource", + "$ref": "#/components/schemas/SingleResourceMeta", + "nullable": true + } + } + }, + "OAuth2Session": { + "description": "A OAuth 2.0 session", + "type": "object", + "required": [ + "client_id", + "created_at", + "scope" + ], + "properties": { + "created_at": { + "description": "When the object was created", + "type": "string", + "format": "date-time" + }, + "finished_at": { + "description": "When the session was finished", + "type": "string", + "format": "date-time", + "nullable": true + }, + "user_id": { + "description": "The ID of the user who owns the session", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "user_session_id": { + "description": "The ID of the browser session which started this session", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "client_id": { + "description": "The ID of the client which requested this session", + "$ref": "#/components/schemas/ULID" + }, + "scope": { + "description": "The scope granted for this session", "type": "string" }, - "next": { - "description": "The link to the next page of results\n\nOnly present if there is a next page", + "user_agent": { + "description": "The user agent string of the client which started this session", + "type": "string", + "nullable": true + }, + "last_active_at": { + "description": "The last time the session was active", "type": "string", + "format": "date-time", "nullable": true }, - "prev": { - "description": "The link to the previous page of results\n\nOnly present if there is a previous page", + "last_active_ip": { + "description": "The last IP address used by the session", + "type": "string", + "format": "ip", + "nullable": true + }, + "human_name": { + "description": "The user-provided name, if any", "type": "string", "nullable": true } } }, - "ErrorResponse": { - "description": "A top-level response with a list of errors", - "type": "object", - "required": [ - "errors" - ], - "properties": { - "errors": { - "description": "The list of errors", - "type": "array", - "items": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "Error": { - "description": "A single error", - "type": "object", - "required": [ - "title" - ], - "properties": { - "title": { - "description": "A human-readable title for the error", - "type": "string" - } - } - }, - "UlidInPath": { - "type": "object", - "required": [ - "id" - ], - "properties": { - "id": { - "title": "The ID of the resource", - "$ref": "#/components/schemas/ULID" - } - } - }, - "SingleResponse_for_CompatSession": { + "SingleResponse_for_OAuth2Session": { "description": "A top-level response with a single resource", "type": "object", "required": [ @@ -3757,33 +5168,28 @@ ], "properties": { "data": { - "$ref": "#/components/schemas/SingleResource_for_CompatSession" + "$ref": "#/components/schemas/SingleResource_for_OAuth2Session" }, "links": { "$ref": "#/components/schemas/SelfLinks" } } }, - "OAuth2SessionFilter": { + "PersonalSessionFilter": { "type": "object", "properties": { - "filter[user]": { - "description": "Retrieve the items for the given user", + "filter[owner_user]": { + "description": "Filter by owner user ID", "$ref": "#/components/schemas/ULID", "nullable": true }, - "filter[client]": { - "description": "Retrieve the items for the given client", + "filter[owner_client]": { + "description": "Filter by owner `OAuth2` client ID", "$ref": "#/components/schemas/ULID", "nullable": true }, - "filter[client-kind]": { - "description": "Retrieve the items only for a specific client kind", - "$ref": "#/components/schemas/OAuth2ClientKind", - "nullable": true - }, - "filter[user-session]": { - "description": "Retrieve the items started from the given browser session", + "filter[actor_user]": { + "description": "Filter by actor user ID", "$ref": "#/components/schemas/ULID", "nullable": true }, @@ -3796,45 +5202,55 @@ } }, "filter[status]": { - "description": "Retrieve the items with the given status\n\nDefaults to retrieve all sessions, including finished ones.\n\n* `active`: Only retrieve active sessions\n\n* `finished`: Only retrieve finished sessions", - "$ref": "#/components/schemas/OAuth2SessionStatus", + "description": "Filter by session status", + "$ref": "#/components/schemas/PersonalSessionStatus", + "nullable": true + }, + "filter[expires_before]": { + "description": "Filter by access token expiry date", + "type": "string", + "format": "date-time", + "nullable": true + }, + "filter[expires_after]": { + "description": "Filter by access token expiry date", + "type": "string", + "format": "date-time", + "nullable": true + }, + "filter[expires]": { + "description": "Filter by whether the access token has an expiry time", + "type": "boolean", "nullable": true } } }, - "OAuth2ClientKind": { - "type": "string", - "enum": [ - "dynamic", - "static" - ] - }, - "OAuth2SessionStatus": { + "PersonalSessionStatus": { "type": "string", "enum": [ "active", - "finished" + "revoked" ] }, - "PaginatedResponse_for_OAuth2Session": { + "PaginatedResponse_for_PersonalSession": { "description": "A top-level response with a page of resources", "type": "object", "required": [ - "data", - "links", - "meta" + "links" ], "properties": { "meta": { "description": "Response metadata", - "$ref": "#/components/schemas/PaginationMeta" + "$ref": "#/components/schemas/PaginationMeta", + "nullable": true }, "data": { "description": "The list of resources", "type": "array", "items": { - "$ref": "#/components/schemas/SingleResource_for_OAuth2Session" - } + "$ref": "#/components/schemas/SingleResource_for_PersonalSession" + }, + "nullable": true }, "links": { "description": "Related links", @@ -3842,7 +5258,7 @@ } } }, - "SingleResource_for_OAuth2Session": { + "SingleResource_for_PersonalSession": { "description": "A single resource, with its type, ID, attributes and related links", "type": "object", "required": [ @@ -3862,77 +5278,118 @@ }, "attributes": { "description": "The attributes of the resource", - "$ref": "#/components/schemas/OAuth2Session" + "$ref": "#/components/schemas/PersonalSession" }, "links": { "description": "Related links", "$ref": "#/components/schemas/SelfLinks" + }, + "meta": { + "description": "Metadata about the resource", + "$ref": "#/components/schemas/SingleResourceMeta", + "nullable": true } } }, - "OAuth2Session": { - "description": "A OAuth 2.0 session", + "PersonalSession": { + "description": "A personal session (session using personal access tokens)", "type": "object", "required": [ - "client_id", + "actor_user_id", "created_at", + "human_name", "scope" ], "properties": { "created_at": { - "description": "When the object was created", + "description": "When the session was created", "type": "string", "format": "date-time" }, - "finished_at": { - "description": "When the session was finished", + "revoked_at": { + "description": "When the session was revoked, if applicable", "type": "string", "format": "date-time", "nullable": true }, - "user_id": { - "description": "The ID of the user who owns the session", + "owner_user_id": { + "description": "The ID of the user who owns this session (if user-owned)", "$ref": "#/components/schemas/ULID", "nullable": true }, - "user_session_id": { - "description": "The ID of the browser session which started this session", + "owner_client_id": { + "description": "The ID of the `OAuth2` client that owns this session (if client-owned)", "$ref": "#/components/schemas/ULID", "nullable": true }, - "client_id": { - "description": "The ID of the client which requested this session", + "actor_user_id": { + "description": "The ID of the user that the session acts on behalf of", "$ref": "#/components/schemas/ULID" }, - "scope": { - "description": "The scope granted for this session", + "human_name": { + "description": "Human-readable name for the session", "type": "string" }, - "user_agent": { - "description": "The user agent string of the client which started this session", - "type": "string", - "nullable": true + "scope": { + "description": "`OAuth2` scopes for this session", + "type": "string" }, "last_active_at": { - "description": "The last time the session was active", + "description": "When the session was last active", "type": "string", "format": "date-time", "nullable": true }, "last_active_ip": { - "description": "The last IP address used by the session", + "description": "IP address of last activity", "type": "string", "format": "ip", "nullable": true }, - "human_name": { - "description": "The user-provided name, if any", + "expires_at": { + "description": "When the current token for this session expires. The session will need to be regenerated, producing a new access token, after this time. None if the current token won't expire or if the session is revoked.", + "type": "string", + "format": "date-time", + "nullable": true + }, + "access_token": { + "description": "The actual access token (only returned on creation)", "type": "string", "nullable": true } } }, - "SingleResponse_for_OAuth2Session": { + "CreatePersonalSessionRequest": { + "title": "JSON payload for the `POST /api/admin/v1/personal-sessions` endpoint", + "type": "object", + "required": [ + "actor_user_id", + "human_name", + "scope" + ], + "properties": { + "actor_user_id": { + "description": "The user this session will act on behalf of", + "$ref": "#/components/schemas/ULID" + }, + "human_name": { + "description": "Human-readable name for the session", + "type": "string" + }, + "scope": { + "description": "`OAuth2` scopes for this session", + "type": "string" + }, + "expires_in": { + "description": "Token expiry time in seconds. If not set, the token won't expire.", + "type": "integer", + "format": "uint32", + "minimum": 0.0, + "nullable": true + } + } + }, + "SingleResponse_for_PersonalSession": { "description": "A top-level response with a single resource", "type": "object", "required": [ @@ -3941,13 +5398,26 @@ ], "properties": { "data": { - "$ref": "#/components/schemas/SingleResource_for_OAuth2Session" + "$ref": "#/components/schemas/SingleResource_for_PersonalSession" }, "links": { "$ref": "#/components/schemas/SelfLinks" } } }, + "RegeneratePersonalSessionRequest": { + "title": "JSON payload for the `POST /api/admin/v1/personal-sessions/{id}/regenerate` endpoint", + "type": "object", + "properties": { + "expires_in": { + "description": "Token expiry time in seconds. If not set, the token won't expire.", + "type": "integer", + "format": "uint32", + "minimum": 0.0, + "nullable": true + } + } + }, "SetPolicyDataRequest": { "title": "JSON payload for the `POST /api/admin/v1/policy-data`", "type": "object", @@ -4007,6 +5477,11 @@ "links": { "description": "Related links", "$ref": "#/components/schemas/SelfLinks" + }, + "meta": { + "description": "Metadata about the resource", + "$ref": "#/components/schemas/SingleResourceMeta", + "nullable": true } } }, @@ -4065,21 +5540,21 @@ "description": "A top-level response with a page of resources", "type": "object", "required": [ - "data", - "links", - "meta" + "links" ], "properties": { "meta": { "description": "Response metadata", - "$ref": "#/components/schemas/PaginationMeta" + "$ref": "#/components/schemas/PaginationMeta", + "nullable": true }, "data": { "description": "The list of resources", "type": "array", "items": { "$ref": "#/components/schemas/SingleResource_for_User" - } + }, + "nullable": true }, "links": { "description": "Related links", @@ -4112,6 +5587,11 @@ "links": { "description": "Related links", "$ref": "#/components/schemas/SelfLinks" + }, + "meta": { + "description": "Metadata about the resource", + "$ref": "#/components/schemas/SingleResourceMeta", + "nullable": true } } }, @@ -4266,21 +5746,21 @@ "description": "A top-level response with a page of resources", "type": "object", "required": [ - "data", - "links", - "meta" + "links" ], "properties": { "meta": { "description": "Response metadata", - "$ref": "#/components/schemas/PaginationMeta" + "$ref": "#/components/schemas/PaginationMeta", + "nullable": true }, "data": { "description": "The list of resources", "type": "array", "items": { "$ref": "#/components/schemas/SingleResource_for_UserEmail" - } + }, + "nullable": true }, "links": { "description": "Related links", @@ -4313,6 +5793,11 @@ "links": { "description": "Related links", "$ref": "#/components/schemas/SelfLinks" + }, + "meta": { + "description": "Metadata about the resource", + "$ref": "#/components/schemas/SingleResourceMeta", + "nullable": true } } }, @@ -4401,21 +5886,21 @@ "description": "A top-level response with a page of resources", "type": "object", "required": [ - "data", - "links", - "meta" + "links" ], "properties": { "meta": { "description": "Response metadata", - "$ref": "#/components/schemas/PaginationMeta" + "$ref": "#/components/schemas/PaginationMeta", + "nullable": true }, "data": { "description": "The list of resources", "type": "array", "items": { "$ref": "#/components/schemas/SingleResource_for_UserSession" - } + }, + "nullable": true }, "links": { "description": "Related links", @@ -4448,6 +5933,11 @@ "links": { "description": "Related links", "$ref": "#/components/schemas/SelfLinks" + }, + "meta": { + "description": "Metadata about the resource", + "$ref": "#/components/schemas/SingleResourceMeta", + "nullable": true } } }, @@ -4538,21 +6028,21 @@ "description": "A top-level response with a page of resources", "type": "object", "required": [ - "data", - "links", - "meta" + "links" ], "properties": { "meta": { "description": "Response metadata", - "$ref": "#/components/schemas/PaginationMeta" + "$ref": "#/components/schemas/PaginationMeta", + "nullable": true }, "data": { "description": "The list of resources", "type": "array", "items": { "$ref": "#/components/schemas/SingleResource_for_UserRegistrationToken" - } + }, + "nullable": true }, "links": { "description": "Related links", @@ -4585,6 +6075,11 @@ "links": { "description": "Related links", "$ref": "#/components/schemas/SelfLinks" + }, + "meta": { + "description": "Metadata about the resource", + "$ref": "#/components/schemas/SingleResourceMeta", + "nullable": true } } }, @@ -4727,21 +6222,21 @@ "description": "A top-level response with a page of resources", "type": "object", "required": [ - "data", - "links", - "meta" + "links" ], "properties": { "meta": { "description": "Response metadata", - "$ref": "#/components/schemas/PaginationMeta" + "$ref": "#/components/schemas/PaginationMeta", + "nullable": true }, "data": { "description": "The list of resources", "type": "array", "items": { "$ref": "#/components/schemas/SingleResource_for_UpstreamOAuthLink" - } + }, + "nullable": true }, "links": { "description": "Related links", @@ -4774,6 +6269,11 @@ "links": { "description": "Related links", "$ref": "#/components/schemas/SelfLinks" + }, + "meta": { + "description": "Metadata about the resource", + "$ref": "#/components/schemas/SingleResourceMeta", + "nullable": true } } }, @@ -4869,21 +6369,35 @@ "description": "A top-level response with a page of resources", "type": "object", "required": [ +<<<<<<< HEAD "data", "links", "meta" +======= + "links" +>>>>>>> v1.6.0 ], "properties": { "meta": { "description": "Response metadata", +<<<<<<< HEAD "$ref": "#/components/schemas/PaginationMeta" +======= + "$ref": "#/components/schemas/PaginationMeta", + "nullable": true +>>>>>>> v1.6.0 }, "data": { "description": "The list of resources", "type": "array", "items": { "$ref": "#/components/schemas/SingleResource_for_UpstreamOAuthProvider" +<<<<<<< HEAD } +======= + }, + "nullable": true +>>>>>>> v1.6.0 }, "links": { "description": "Related links", @@ -4916,6 +6430,14 @@ "links": { "description": "Related links", "$ref": "#/components/schemas/SelfLinks" +<<<<<<< HEAD +======= + }, + "meta": { + "description": "Metadata about the resource", + "$ref": "#/components/schemas/SingleResourceMeta", + "nullable": true +>>>>>>> v1.6.0 } } }, @@ -4953,6 +6475,25 @@ "nullable": true } } +<<<<<<< HEAD +======= + }, + "SingleResponse_for_UpstreamOAuthProvider": { + "description": "A top-level response with a single resource", + "type": "object", + "required": [ + "data", + "links" + ], + "properties": { + "data": { + "$ref": "#/components/schemas/SingleResource_for_UpstreamOAuthProvider" + }, + "links": { + "$ref": "#/components/schemas/SelfLinks" + } + } +>>>>>>> v1.6.0 } } }, diff --git a/docs/config.schema.json b/docs/config.schema.json index ada9005c7..524f02c93 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2604,6 +2604,10 @@ "description": "Whether to enable self-service password registration. Defaults to `false` if password authentication is enabled.\n\nThis has no effect if password login is disabled.", "type": "boolean" }, + "password_registration_email_required": { + "description": "Whether self-service password registrations require a valid email. Defaults to `true`.\n\nThis has no effect if password registration is disabled.", + "type": "boolean" + }, "password_change_allowed": { "description": "Whether users are allowed to change their passwords. Defaults to `true`.\n\nThis has no effect if password login is disabled.", "type": "boolean" diff --git a/docs/reference/cli/manage.md b/docs/reference/cli/manage.md index 5b107cd52..d633c4108 100644 --- a/docs/reference/cli/manage.md +++ b/docs/reference/cli/manage.md @@ -119,8 +119,11 @@ $ mas-cli manage lock-user --deactivate Unlock a user. +Options: +- `--reactivate`: Whether to reactivate the user. + ``` -$ mas-cli manage unlock-user +$ mas-cli manage unlock-user --reactivate ``` ## `manage register-user` diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index b292aa332..c5b69e38f 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -296,6 +296,12 @@ account: # This has no effect if password login is disabled. password_registration_enabled: false + # Whether self-service registrations require a valid email + # + # Defaults to `true` + # This has no effect if password registration is disabled. + password_registration_email_required: true + # Whether users are allowed to change their passwords # # Defaults to `true`. @@ -759,7 +765,7 @@ upstream_oauth2: localpart: #action: force #template: "{{ user.preferred_username }}" - + # How to handle when localpart already exists. # Possible values are (default: fail): # - `add` : Adds the upstream account link to the existing user, regardless of whether there is an existing link or not. diff --git a/frontend/.storybook/locales.ts b/frontend/.storybook/locales.ts index c2acf82ed..22f9976a8 100644 --- a/frontend/.storybook/locales.ts +++ b/frontend/.storybook/locales.ts @@ -27,7 +27,11 @@ export type LocalazyMetadata = { }; const localazyMetadata: LocalazyMetadata = { +<<<<<<< HEAD projectUrl: "https://localazy.com/p/matrix-authentication-service!v1.3", +======= + projectUrl: "https://localazy.com/p/matrix-authentication-service!v1.6", +>>>>>>> v1.6.0 baseLocale: "en", languages: [ { @@ -172,6 +176,7 @@ const localazyMetadata: LocalazyMetadata = { file: "frontend.json", path: "", cdnFiles: { +<<<<<<< HEAD "cs": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json", "da": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json", "de": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json", @@ -187,6 +192,23 @@ const localazyMetadata: LocalazyMetadata = { "sv": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json", "uk": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json", "zh#Hans": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json" +======= + "cs": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json", + "da": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json", + "de": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json", + "en": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json", + "et": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json", + "fi": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json", + "fr": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json", + "hu": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json", + "nb_NO": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json", + "nl": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json", + "pt": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json", + "ru": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json", + "sv": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json", + "uk": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json", + "zh#Hans": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json" +>>>>>>> v1.6.0 } }, { @@ -194,6 +216,7 @@ const localazyMetadata: LocalazyMetadata = { file: "file.json", path: "", cdnFiles: { +<<<<<<< HEAD "cs": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json", "da": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json", "de": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json", @@ -209,6 +232,23 @@ const localazyMetadata: LocalazyMetadata = { "sv": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json", "uk": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json", "zh#Hans": "https://delivery.localazy.com/_a6804865183625420345536edd7e/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json" +======= + "cs": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json", + "da": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json", + "de": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json", + "en": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json", + "et": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json", + "fi": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json", + "fr": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json", + "hu": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json", + "nb_NO": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json", + "nl": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json", + "pt": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json", + "ru": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json", + "sv": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json", + "uk": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json", + "zh#Hans": "https://delivery.localazy.com/_a67480892591190493723a576eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json" +>>>>>>> v1.6.0 } } ] diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index d895a0bc9..b4ffa1976 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -9,7 +9,7 @@ import type { StorybookConfig } from "@storybook/react-vite"; const config: StorybookConfig = { stories: ["../{src,stories}/**/*.stories.@(js|jsx|ts|tsx)"], - addons: ["storybook-react-i18next", "@storybook/addon-docs"], + addons: ["@storybook/addon-docs"], framework: "@storybook/react-vite", diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index a9abadc7d..7ba9e4218 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -4,15 +4,11 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. -import type { - ArgTypes, - Decorator, - Parameters, - Preview, -} from "@storybook/react-vite"; +import type { Decorator, Preview } from "@storybook/react-vite"; import { TooltipProvider } from "@vector-im/compound-web"; import { initialize, mswLoader } from "msw-storybook-addon"; -import { useLayoutEffect } from "react"; +import { useEffect, useLayoutEffect } from "react"; +import { I18nextProvider } from "react-i18next"; import "../src/shared.css"; import i18n, { setupI18n } from "../src/i18n"; import { DummyRouter } from "../src/test-utils/router"; @@ -31,37 +27,12 @@ initialize( setupI18n(); -export const parameters: Parameters = { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/, - }, - }, -}; - -export const globalTypes = { - theme: { - name: "Theme", - defaultValue: "system", - description: "Global theme for components", - toolbar: { - icon: "circlehollow", - title: "Theme", - items: [ - { title: "System", value: "system", icon: "browser" }, - { title: "Light", value: "light", icon: "sun" }, - { title: "Light (high contrast)", value: "light-hc", icon: "sun" }, - { title: "Dark", value: "dark", icon: "moon" }, - { title: "Dark (high contrast)", value: "dark-hc", icon: "moon" }, - ], - }, - }, -} satisfies ArgTypes; - -const allThemesClasses = globalTypes.theme.toolbar.items.map( - ({ value }) => `cpd-theme-${value}`, -); +const allThemesClasses = [ + "cpd-theme-light", + "cpd-theme-light-hc", + "cpd-theme-dark", + "cpd-theme-dark-hc", +]; const ThemeSwitcher: React.FC<{ theme: string; @@ -86,6 +57,27 @@ const withThemeProvider: Decorator = (Story, context) => { ); }; +const LocaleSwitcher: React.FC<{ + locale: string; +}> = ({ locale }) => { + useEffect(() => { + i18n.changeLanguage(locale); + }, [locale]); + + return null; +}; + +const withI18nProvider: Decorator = (Story, context) => { + return ( + <> + + + + + + ); +}; + const withDummyRouter: Decorator = (Story, _context) => { return ( @@ -102,28 +94,58 @@ const withTooltipProvider: Decorator = (Story, _context) => { ); }; -export const decorators: Decorator[] = [ - withThemeProvider, - withDummyRouter, - withTooltipProvider, -]; - -const locales = Object.fromEntries( - localazyMetadata.languages.map(({ language, name, localizedName }) => [ - language, - `${localizedName} (${name})`, - ]), -); - const preview: Preview = { + loaders: [mswLoader], + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + }, + decorators: [ + withI18nProvider, + withThemeProvider, + withDummyRouter, + withTooltipProvider, + ], + globalTypes: { + theme: { + name: "Theme", + description: "Global theme for components", + toolbar: { + icon: "circlehollow", + title: "Theme", + items: [ + { title: "System", value: "system", icon: "browser" }, + { title: "Light", value: "light", icon: "sun" }, + { title: "Light (high contrast)", value: "light-hc", icon: "sun" }, + { title: "Dark", value: "dark", icon: "moon" }, + { title: "Dark (high contrast)", value: "dark-hc", icon: "moon" }, + ], + }, + }, + + locale: { + name: "Locale", + description: "Locale for the app", + toolbar: { + title: "Language", + icon: "globe", + items: localazyMetadata.languages.map( + ({ language, localizedName, name }) => ({ + title: `${localizedName} (${name})`, + value: language, + }), + ), + }, + }, + }, initialGlobals: { locale: localazyMetadata.baseLocale, - locales, - }, - parameters: { - i18n, + theme: "system", }, - loaders: [mswLoader], tags: ["autodocs"], }; diff --git a/frontend/.storybook/public/mockServiceWorker.js b/frontend/.storybook/public/mockServiceWorker.js index 2eec3ee33..8ce79941c 100644 --- a/frontend/.storybook/public/mockServiceWorker.js +++ b/frontend/.storybook/public/mockServiceWorker.js @@ -7,7 +7,11 @@ * - Please do NOT modify this file. */ +<<<<<<< HEAD const PACKAGE_VERSION = '2.11.2' +======= +const PACKAGE_VERSION = '2.11.6' +>>>>>>> v1.6.0 const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() @@ -205,6 +209,7 @@ async function resolveMainClient(event) { * @param {FetchEvent} event * @param {Client | undefined} client * @param {string} requestId + * @param {number} requestInterceptedAt * @returns {Promise} */ async function getResponse(event, client, requestId, requestInterceptedAt) { diff --git a/frontend/locales/de.json b/frontend/locales/de.json index b5bcfe437..75c8493ed 100644 --- a/frontend/locales/de.json +++ b/frontend/locales/de.json @@ -5,7 +5,7 @@ "clear": "Löschen", "close": "Schließen", "collapse": "Zusammenbruch", - "confirm": " Bestätigen Sie", + "confirm": "Bestätigen", "continue": "Weiter", "edit": "Bearbeiten", "expand": "Erweitern", @@ -40,16 +40,16 @@ "account_password": "Kontokennwort", "contact_info": "Kontaktinformation", "delete_account": { - "alert_description": "Dieses Konto wird dauerhaft gelöscht und Sie haben keinen Zugriff mehr auf Ihre Nachrichten.", - "alert_title": "Sie sind dabei, alle Ihre Daten zu verlieren", + "alert_description": "Dieses Konto wird dauerhaft entfernt und du hast keinen Zugriff mehr auf deine Nachrichten.", + "alert_title": "Du bist kurz davor, alle deine Daten zu verlieren.", "button": "Account löschen", - "dialog_description": "Bestätigen Sie, dass Sie Ihr Konto löschen möchten:\n\nSie können Ihr Konto nicht reaktivieren\nSie können sich nicht mehr anmelden\nNiemand kann Ihren Benutzernamen (MXID) wieder verwenden, auch Sie nicht.\nSie verlassen alle Räume und Direktnachrichten, in denen Sie sich befinden\nSie werden vom Identitätsserver entfernt und niemand kann Sie mit Ihrer E-Mail-Adresse oder Telefonnummer finden\n\nIhre alten Nachrichten sind für Empfänger weiterhin sichtbar. Möchten Sie Ihre gesendeten Nachrichten vor zukünftigen Chatroom-Besuchern verbergen?", + "dialog_description": "Bestätige, dass du dein Konto löschen möchtest:\n\nDu kannst dein Konto nicht reaktivieren\nDu kannst dich nicht mehr anmelden\nNiemand kann deinen Benutzernamen (MXID) wieder verwenden, auch du nicht.\nDu verlässt alle Gruppen und Chats\nDu wirst vom Identitätsserver entfernt und niemand kann dich mit deiner E-Mail-Adresse oder Telefonnummer finden\n\nDeine alten Nachrichten sind für Empfänger weiterhin sichtbar. Möchtest du deine gesendeten Nachrichten vor zukünftigen Gruppen-Besuchern verbergen?", "dialog_title": "Dieses Konto löschen?", "erase_checkbox_label": "Ja, alle meine Nachrichten vor neuen Mitgliedern verbergen", - "incorrect_password": "Falsches Passwort, bitte versuchen Sie es erneut", - "mxid_label": "Bestätigen Sie Ihre Matrix-ID ({{ mxid }})", - "mxid_mismatch": "Dieser Wert stimmt nicht mit Ihrer Matrix-ID überein", - "password_label": "Geben Sie Ihr Passwort ein, um fortzufahren" + "incorrect_password": "Falsches Passwort, versuch's nochmal", + "mxid_label": "Bestätige deine Matrix-ID ({{ mxid }})", + "mxid_mismatch": "Dieser Wert passt nicht zu deiner Matrix-ID.", + "password_label": "Gib dein Passwort ein, um weiterzumachen" }, "edit_profile": { "display_name_help": "Dies ist der öffentliche Nutzername.", @@ -79,7 +79,7 @@ "title": "Diese E-Mailadresse existiert bereits" }, "email_exists_error": "Die eingegebene E-Mail-Adresse ist diesem Konto bereits zugeordnet", - "email_field_help": "Fügen Sie eine alternative E-Mail-Adresse hinzu, mit der Sie auf dieses Konto zugreifen können.", + "email_field_help": "Gib eine alternative E-Mail-Adresse an, mit der du auf dieses Konto zugreifen kannst.", "email_field_label": "E-Mail-Adresse hinzufügen", "email_in_use_error": "Die eingegebene E-Mail wird bereits verwendet", "email_invalid_alert": { @@ -87,8 +87,8 @@ "title": "Ungültige Email-Adresse" }, "email_invalid_error": "Die eingegebene E-Mail-Adresse ist ungültig", - "incorrect_password_error": "Falsches Passwort, bitte versuchen Sie es erneut", - "password_confirmation": "Bestätigen Sie Ihr Kontopasswort, um diese E-Mail-Adresse hinzuzufügen" + "incorrect_password_error": "Falsches Passwort, versuch's nochmal", + "password_confirmation": "Bestätige dein Passwort, um diese E-Mail-Adresse hinzuzufügen." }, "app_sessions_list": { "error": "App-Sitzungen konnten nicht geladen werden", @@ -103,8 +103,8 @@ "body:other": "{{count}} aktive Sitzungen", "heading": "Browser", "no_active_sessions": { - "default": "Sie sind bei keinem Webbrowser angemeldet.", - "inactive_90_days": "Alle Ihre Sitzungen waren in den letzten 90 Tagen aktiv." + "default": "Du bist in keinem Webbrowser angemeldet.", + "inactive_90_days": "Alle deine Sitzungen waren in den letzten 90 Tagen aktiv." }, "view_all_button": "Alle anzeigen" }, @@ -125,19 +125,19 @@ "heading": "Die E-Mail-Adresse {{email}} wird bereits verwendet." }, "end_session_button": { - "confirmation_modal_title": "Sind Sie sicher, dass Sie diese Sitzung abmelden möchten?", + "confirmation_modal_title": "Möchtest du diese Sitzung wirklich beenden?", "text": "Gerät entfernen" }, "error": { "hideDetails": "Details ausblenden", "showDetails": "Details anzeigen", - "subtitle": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut", + "subtitle": "Ein unerwarteter Fehler ist aufgetreten, bitte versuch's nochmal.", "title": "Etwas ist schief gelaufen" }, "error_boundary_title": "Etwas ist schief gelaufen", "errors": { "field_required": "Dieses Feld ist ein Pflichtfeld", - "rate_limit_exceeded": "Sie haben in kurzer Zeit zu viele Anfragen gestellt. Bitte warten Sie einige Minuten und versuchen Sie es erneut." + "rate_limit_exceeded": "Du hast in kurzer Zeit zu viele Anfragen gestellt. Warte bitte ein paar Minuten und versuch's nochmal." }, "last_active": { "active_date": "Aktiv {{relativeDate}}", @@ -146,13 +146,13 @@ }, "nav": { "devices": "Geräte", - "plan": "Plan", + "plan": "Abo", "profile": "Profil", "sessions": "Sitzungen", "settings": "Einstellungen" }, "not_found_alert_title": "Nicht gefunden.", - "not_logged_in_alert": "Sie sind nicht angemeldet.", + "not_logged_in_alert": "Du bist nicht angemeldet.", "oauth2_client_detail": { "details_title": "Geräte Information", "id": "Client-ID", @@ -172,15 +172,15 @@ "current_password_label": "Aktuelles Passwort", "failure": { "description": { - "account_locked": "Ihr Konto ist gesperrt und kann derzeit nicht wiederhergestellt werden. Wenn dies unerwartet auftritt, wenden Sie sich bitte an Ihren Serveradministrator.", - "expired_recovery_ticket": "Der Wiederherstellungslink ist abgelaufen. Bitte starten Sie den Kontowiederherstellungsprozess erneut von Anfang an.", - "invalid_new_password": "Das neue Passwort, das Sie gewählt haben, ist ungültig; es entspricht möglicherweise nicht der konfigurierten Sicherheitsrichtlinie.", - "no_current_password": "Sie haben kein aktuelles Passwort.", - "no_such_recovery_ticket": "Der Wiederherstellungslink ist ungültig. Wenn Sie den Link aus der Wiederherstellungs-E-Mail kopiert haben, überprüfen Sie bitte, ob der vollständige Link kopiert wurde.", + "account_locked": "Dein Konto ist gesperrt und kann im Moment nicht wiederhergestellt werden. Wenn du das nicht erwartet hast, wende dich bitte an deinen Server-Admin.", + "expired_recovery_ticket": "Der Link zur Kontowiederherstellung ist abgelaufen. Bitte fang den Prozess noch mal von vorne an.", + "invalid_new_password": "Das neue Passwort, das du gewählt hast, ist ungültig; es entspricht möglicherweise nicht den Sicherheitsrichtlinien.", + "no_current_password": "Du hast kein aktuelles Passwort.", + "no_such_recovery_ticket": "Der Link zum Wiederherstellen ist nicht gültig. Wenn du den Link aus der E-Mail zum Wiederherstellen kopiert hast, schau bitte nach, ob du den vollständigen Link kopiert hast.", "password_changes_disabled": "Passwortänderungen sind deaktiviert.", "recovery_ticket_already_used": "Der Wiederherstellungslink wurde bereits verwendet. Er kann nicht erneut verwendet werden.", - "unspecified": "Dies könnte ein vorübergehendes Problem sein. Bitte versuchen Sie es später erneut. Wenn das Problem weiterhin besteht, wenden Sie sich bitte an Ihren Serveradministrator.", - "wrong_password": "Das Passwort, das Sie als Ihr aktuelles Passwort eingegeben haben, ist falsch. Bitte versuchen Sie es erneut." + "unspecified": "Das könnte ein vorübergehendes Problem sein, also versuch's später nochmal. Wenn das Problem weiterhin besteht, wende dich bitte an deinen Server-Admin.", + "wrong_password": "Das Passwort, das du als dein aktuelles Passwort angegeben hast, ist falsch. Versuch's bitte nochmal." }, "title": "Aktualisierung des Passworts fehlgeschlagen" }, @@ -188,25 +188,25 @@ "new_password_label": "Neues Passwort", "passwords_match": "Passwörter stimmen überein!", "passwords_no_match": "Passwörter stimmen nicht überein", - "subtitle": "Wählen Sie ein neues Passwort für Ihren Account.", + "subtitle": "Such dir ein neues Passwort für dein Konto aus.", "success": { - "description": "Ihr Passwort wurde erfolgreich aktualisiert.", - "title": "Passwort aktualisiert" + "description": "Dein Passwort wurde geändert.", + "title": "Passwort geändert" }, - "title": "Ändern Sie Ihr Passwort" + "title": "Ändere dein Passwort" }, "password_reset": { "consumed": { - "subtitle": "Um ein neues Passwort zu erstellen, beginnen Sie von vorne und wählen Sie „Passwort vergessen“.", - "title": "Der Link zum Zurücksetzen Ihres Passworts wurde bereits verwendet" + "subtitle": "Um ein neues Passwort zu erstellen, fang einfach von vorne an und wähle „Passwort vergessen“.", + "title": "Der Link zum Zurücksetzen deines Passworts wurde bereits verwendet" }, "expired": { "resend_email": "E-Mail erneut senden", - "subtitle": "Fordern Sie eine neue E-Mail an, die an folgende Adresse gesendet wird: {{email}}", - "title": "Der Link zum Zurücksetzen Ihres Passworts ist abgelaufen" + "subtitle": "Eine neue E-Mail anfordern, die an folgende Adresse gesendet wird: {{email}}", + "title": "Der Link zum Zurücksetzen deines Passworts ist abgelaufen" }, - "subtitle": "Wählen Sie ein neues Passwort für Ihren Account.", - "title": "Ihr Passwort zurücksetzen" + "subtitle": "Such dir ein neues Passwort für dein Konto aus.", + "title": "Setze dein Passwort zurück" }, "password_strength": { "placeholder": "Passwortstärke", @@ -218,20 +218,20 @@ "4": "Sehr starkes Passwort" }, "suggestion": { - "all_uppercase": "Schreiben Sie einige, aber nicht alle Buchstaben groß.", - "another_word": "Fügen Sie weitere Wörter hinzu, die weniger gebräuchlich sind.", - "associated_years": "Vermeiden Sie Jahre, die mit Ihnen in Verbindung gebracht werden.", - "capitalization": "Schreiben Sie mehr als den ersten Buchstaben groß.", - "dates": "Vermeiden Sie Daten und Jahre, die mit Ihnen in Verbindung gebracht werden.", - "l33t": "Vermeiden Sie vorhersehbare Buchstabenersetzungen wie '@' für 'a'.", - "longer_keyboard_pattern": "Verwenden Sie längere Eingaben und ändern Sie die Tipprichtung mehrmals.", - "no_need": "Sie können sichere Passwörter erstellen, ohne Symbole, Zahlen oder Großbuchstaben zu verwenden.", - "pwned": "Wenn Sie dieses Passwort woanders verwenden, sollten Sie es ändern.", - "recent_years": "Vermeiden Sie die letzten Jahre.", - "repeated": "Vermeiden Sie Wort- und Zeichenwiederholungen.", - "reverse_words": "Vermeiden Sie umgekehrte Schreibweisen gängiger Wörter.", - "sequences": "Vermeiden Sie gängige Zeichenfolgen.", - "use_words": "Verwenden Sie mehrere Wörter, vermeiden Sie jedoch gebräuchliche Ausdrücke." + "all_uppercase": "Schreib ein paar Buchstaben groß, aber nicht alle.", + "another_word": "Füge mehr Wörter hinzu, die weniger gebräuchlich sind.", + "associated_years": "Vermeide Jahre, die mit dir in Verbindung stehen.", + "capitalization": "Schreib mehr als nur den ersten Buchstaben groß.", + "dates": "Vermeide Daten und Jahreszahlen, die mit dir in Verbindung stehen.", + "l33t": "Vermeide vorhersehbare Buchstabenersetzungen wie „@“ für „a“.", + "longer_keyboard_pattern": "Benutz längere Tastaturmuster und wechsel mehrmals die Schreibrichtung.", + "no_need": "Du kannst starke Passwörter erstellen, ohne Symbole, Zahlen oder Großbuchstaben zu benutzen.", + "pwned": "Wenn du dieses Passwort auch woanders benutzt, solltest du es ändern.", + "recent_years": "Vermeide die letzten Jahre.", + "repeated": "Vermeide es, Wörter und Zeichen zu wiederholen.", + "reverse_words": "Vermeide es, gängige Wörter rückwärts zu schreiben.", + "sequences": "Vermeide gängige Zeichenfolgen.", + "use_words": "Benutze mehrere Wörter, aber vermeide gängige Redewendungen." }, "too_weak": "Dieses Passwort ist zu schwach", "warning": { @@ -241,12 +241,12 @@ "extended_repeat": "Wiederholte Zeichenmuster wie „abcabcabc“ sind leicht zu erraten.", "key_pattern": "Kurze Eingaben sind leicht zu erraten.", "names_by_themselves": "Einzelne Vor- oder Nachnamen sind leicht zu erraten.", - "pwned": "Ihr Passwort wurde durch eine Datenpanne im Internet preisgegeben.", + "pwned": "Dein Passwort wurde durch eine Datenpanne im Internet preisgegeben.", "recent_years": "Die letzten Jahre sind leicht zu erraten.", "sequences": "Gängige Zeichenfolgen wie „abc“ sind leicht zu erraten.", "similar_to_common": "Dies ähnelt einem häufig verwendeten Passwort.", "simple_repeat": "Sich wiederholende Zeichen wie \"aaa\" sind leicht zu erraten.", - "straight_row": "Gerade Tastenreihen auf Ihrer Tastatur sind leicht zu erraten.", + "straight_row": "Gerade Reihen von Tasten auf deiner Tastatur sind leicht zu erraten.", "top_hundred": "Dies ist ein häufig verwendetes Passwort.", "top_ten": "Dies ist ein häufig verwendetes Passwort.", "user_inputs": "Es sollten keine persönlichen oder seitenbezogenen Daten vorhanden sein.", @@ -256,32 +256,32 @@ "reset_cross_signing": { "button": "Identität zurücksetzen", "cancelled": { - "description_1": "Sie können dieses Fenster schließen und zur App zurückkehren, um fortzufahren.", - "description_2": "Wenn Sie überall abgemeldet sind und Ihren Wiederherstellungscode vergessen haben, müssen Sie Ihre Identität trotzdem zurücksetzen.", + "description_1": "Du kannst dieses Fenster schließen und zur App zurückgehen, um weiterzumachen.", + "description_2": "Wenn du dich überall abgemeldet hast und deinen Wiederherstellungs-Schlüssel nicht mehr weißt, musst du deine Identität zurücksetzen.", "heading": "Identitätszurücksetzung abgebrochen." }, - "description": "Wenn Sie nirgendwo anders angemeldet sind und alle Wiederherstellungsschlüssel verloren haben, müssen Sie Ihre Krypto-Identität zurücksetzen, bevor Sie die App weiterverwenden können", + "description": "Wenn du auf keinem anderen Gerät angemeldet bist und deinen Wiederherstellungs-Schlüssel verloren hast, musst du deine Identität zurücksetzen, um die App weiter nutzen zu können.", "effect_list": { - "negative_1": "Sie werden Ihren bestehenden Nachrichtenverlauf verlieren", - "negative_2": "Sie müssen alle Ihre vorhandenen Geräte und Kontakte erneut überprüfen.", - "neutral_1": "Sie verlieren jeglichen Nachrichtenverlauf, der nur auf dem Server gespeichert ist", - "neutral_2": "Sie müssen alle Ihre vorhandenen Geräte und Kontakte erneut überprüfen.", - "positive_1": "Ihre Kontodaten, Kontakte, Einstellungen und Chat-Liste werden gespeichert" + "negative_1": "Du verlierst deine bestehenden Chats.", + "negative_2": "Du musst alle deine Geräte und Kontakte nochmal verifizieren.", + "neutral_1": "Du verlierst alle Nachrichten, die nur auf dem Server gespeichert sind.", + "neutral_2": "Du musst alle deine Geräte und Kontakte nochmal verifizieren.", + "positive_1": "Deine Kontodaten, Kontakte, Einstellungen und Chat-Liste bleiben erhalten." }, "failure": { - "description": "Dies könnte ein vorübergehendes Problem sein. Bitte versuchen Sie es später erneut. Wenn das Problem weiterhin besteht, wenden Sie sich bitte an Ihren Serveradministrator.", + "description": "Das könnte ein vorübergehendes Problem sein, also versuch's später nochmal. Wenn das Problem weiterhin besteht, wende dich bitte an deinen Server-Admin.", "heading": "Zurücksetzen der Krypto-Identität konnte nicht zugelassen werden", "title": "Krypto-Identität konnte nicht zugelassen werden" }, "finish_reset": "Reset beenden", - "heading": "Setzen Sie Ihre Identität zurück für den Fall, dass Sie sie nicht anders bestätigen können", + "heading": "Erstelle eine neue Identität, solltest du sie nicht auf andere Weise bestätigen können.", "start_reset": "Reset starten", "success": { - "description": "Das Zurücksetzen der Identität wurde für die nächsten {{minutes}} Minuten genehmigt. Sie können dieses Fenster schließen und zur App zurückkehren, um fortzufahren.", - "heading": "Die Identität wurde erfolgreich zurückgesetzt. Kehren Sie zur App zurück, um den Vorgang abzuschließen.", + "description": "Das Zurücksetzen der Identität wurde für die nächsten {{minutes}} Minuten genehmigt. Du kannst dieses Fenster schließen und zur App zurückkehren, um fortzufahren.", + "heading": "Identität erfolgreich zurückgesetzt. Geh zurück zur App, um den Vorgang abzuschließen.", "title": "Das Zurücksetzen der Krypto-Identität ist vorübergehend erlaubt" }, - "warning": "Setzen Sie Ihre Identität nur zurück, wenn Sie keinen Zugriff auf ein anderes angemeldetes Gerät haben und Ihren Wiederherstellungsschlüssel verloren haben." + "warning": "Setze deine Identität nur zurück, wenn du keinen Zugriff auf ein anderes angemeldetes Gerät hast und deinen Wiederherstellungsschlüssel verloren hast." }, "selectable_session": { "label": "Sitzung auswählen" @@ -301,7 +301,7 @@ "name_for_platform": "{{name}} für {{platform}}", "scopes_label": "Berechtigungsumfang", "set_device_name": { - "help": "Geben Sie einen Namen ein, der Ihnen hilft, dieses Gerät zu identifizieren.", + "help": "Gib einen Namen ein, mit dem du dieses Gerät leicht wiederfindest.", "label": "Gerätename", "title": "Gerätename bearbeiten" }, @@ -324,17 +324,17 @@ "unknown_route": "Unbekannte Route {{route}}", "unverified_email_alert": { "button": "Überprüfen und verifizieren", - "text:one": "Sie haben {{count}} nicht verifizierte E-Mail-Adresse.", - "text:other": "Sie haben {{count}} nicht verifizierte E-Mail-Adressen.", + "text:one": "Du hast {{count}} unverifizierte E-Mail-Adresse.", + "text:other": "Du hast {{count}} unverifizierte E-Mail-Adressen.", "title": "Nicht verifizierte E-Mail-Adresse" }, "user_email": { - "cant_delete_primary": "Wählen Sie eine andere primäre E-Mail-Adresse, um diese zu löschen.", + "cant_delete_primary": "Wähle eine andere primäre E-Mail-Adresse aus, um diese zu löschen.", "delete_button_confirmation_modal": { "action": "E-Mail löschen", "body": "Diese E-Mail löschen?", - "incorrect_password": "Falsches Passwort, bitte versuchen Sie es erneut", - "password_confirmation": "Bestätigen Sie Ihr Kontopasswort, um diese E-Mail-Adresse zu löschen" + "incorrect_password": "Falsches Passwort, versuch's nochmal", + "password_confirmation": "Bestätige dein Passwort, um diese E-Mail-Adresse zu löschen." }, "delete_button_title": "E-Mail-Adresse entfernen", "email": "E-Mail", @@ -357,29 +357,29 @@ "user_sessions_overview": { "active_sessions:one": "{{count}} aktive Sitzung", "active_sessions:other": "{{count}} aktive Sitzungen", - "heading": "Wo Sie angemeldet sind", + "heading": "Wo du angemeldet bist", "no_active_sessions": { - "default": "Sie sind bei keiner Anwendung angemeldet.", - "inactive_90_days": "Alle Ihre Sitzungen waren in den letzten 90 Tagen aktiv." + "default": "Du bist bei keiner Anwendung angemeldet.", + "inactive_90_days": "Alle deine Sitzungen waren in den letzten 90 Tagen aktiv." } }, "verify_email": { "code_expired_alert": { - "description": "Der Code ist abgelaufen. Bitte fordern Sie einen neuen Code an.", + "description": "Der Code ist abgelaufen. Bitte fordere einen neuen Code an.", "title": "Code abgelaufen" }, "code_field_error": "Code nicht erkannt", "code_field_label": "6-stelliger Code", "code_field_wrong_shape": "Der Code muss 6-stellig sein", "email_sent_alert": { - "description": "Geben Sie unten den neuen Code ein.", + "description": "Gib den neuen Code unten ein.", "title": "Neuer Code gesendet" }, - "enter_code_prompt": "Geben Sie den 6-stelligen Code ein, der an {{email}} gesendet wurde", - "heading": "Bestätigen Sie Ihre E-Mail-Adresse", + "enter_code_prompt": "Gib den 6-stelligen Code ein, der an {{email}} gesendet wurde", + "heading": "Bestätige deine E-Mail", "invalid_code_alert": { - "description": "Überprüfen Sie den Code, der an Ihre E-Mail-Adresse gesendet wurde, und aktualisieren Sie die folgenden Felder, um fortzufahren.", - "title": "Sie haben einen falschen Code eingegeben" + "description": "Überprüfe den Code, der an deine E-Mail-Adresse gesendet wurde, und aktualisiere die folgenden Felder, um fortzufahren.", + "title": "Du hast den falschen Code eingegeben." }, "resend_code": "Code erneut senden", "resend_email": "E-Mail erneut senden", @@ -389,13 +389,13 @@ }, "mas": { "scope": { - "edit_profile": "Ihr Profil und Ihre Kontaktdaten bearbeiten", - "manage_sessions": "Ihre Geräte und Sitzungen verwalten", + "edit_profile": "Bearbeite dein Profil und deine Kontaktdaten", + "manage_sessions": "Verwalte deine Geräte und Sitzungen", "mas_admin": "Beliebige Benutzer verwalten", - "send_messages": "Nachrichten in Ihrem Namen senden", + "send_messages": "Neue Nachrichten in deinem Namen senden", "synapse_admin": "Den Synapse-Homeserver verwalten", - "view_messages": "Ihre vorhandenen Nachrichten und Daten anzeigen", - "view_profile": "Ihre Profilinformationen und Kontaktdaten anzeigen" + "view_messages": "Zeig deine vorhandenen Nachrichten und Daten an", + "view_profile": "Deine Profilinfos und Kontaktdaten anzeigen" } } } \ No newline at end of file diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index 85ddf2008..743597672 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -147,7 +147,7 @@ }, "nav": { "devices": "Устройства", - "plan": "Plan", + "plan": "Тарифный план", "profile": "Профиль", "sessions": "Сессии", "settings": "Настройки" @@ -302,9 +302,9 @@ "name_for_platform": "{{name}} для {{platform}}", "scopes_label": "Области", "set_device_name": { - "help": "Set a name that will help you identify this device.", - "label": "Device name", - "title": "Edit device name" + "help": "Установите имя, которое поможет вам идентифицировать это устройство.", + "label": "Имя устройства", + "title": "Переименовать устройство" }, "signed_in_date": "Вошёл ", "signed_in_label": "Вошёл в систему", diff --git a/frontend/locales/zh-Hans.json b/frontend/locales/zh-Hans.json index 57e1b4cb3..ec5b8534f 100644 --- a/frontend/locales/zh-Hans.json +++ b/frontend/locales/zh-Hans.json @@ -273,7 +273,7 @@ "title": "无法允许加密身份" }, "finish_reset": "完成重置", - "heading": "重置加密身份", + "heading": "如果你无法通过其它方式确认请重置身份", "start_reset": "开始重置", "success": { "description": "身份重置已获批准,有效时间为 {{minutes}} 分钟。您可以关闭此窗口并返回应用继续操作。", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 12579b921..7fdb15c0d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "mas-frontend", "version": "0.0.0", "dependencies": { +<<<<<<< HEAD "@fontsource/inconsolata": "^5.2.7", "@fontsource/inter": "^5.2.7", "@gouvfr-lasuite/integration": "^1.0.3", @@ -16,20 +17,38 @@ "@tanstack/react-query": "^5.89.0", "@tanstack/react-router": "^1.131.44", "@vector-im/compound-design-tokens": "git+https://github.com/tchapgouv/compound-design-tokens.git", +======= + "@fontsource/inconsolata": "^5.2.8", + "@fontsource/inter": "^5.2.8", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@tanstack/react-query": "^5.90.5", + "@tanstack/react-router": "^1.131.44", + "@vector-im/compound-design-tokens": "6.0.0", +>>>>>>> v1.6.0 "@vector-im/compound-web": "^8.2.4", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "classnames": "^2.5.1", "date-fns": "^4.1.0", +<<<<<<< HEAD "i18next": "^25.5.2", "react": "^19.1.1", "react-dom": "^19.1.1", "react-i18next": "^15.7.3", "swagger-ui-dist": "^5.29.0", +======= + "i18next": "^25.6.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-i18next": "^16.1.4", + "swagger-ui-dist": "^5.29.5", +>>>>>>> v1.6.0 "valibot": "^1.1.0", "vaul": "^1.1.2" }, "devDependencies": { +<<<<<<< HEAD "@biomejs/biome": "^2.2.4", "@browser-logos/chrome": "^2.0.0", "@browser-logos/firefox": "^3.0.10", @@ -50,26 +69,61 @@ "@types/react-dom": "19.1.9", "@types/swagger-ui-dist": "^3.30.6", "@vitejs/plugin-react": "^5.0.2", +======= + "@biomejs/biome": "^2.2.5", + "@browser-logos/chrome": "^2.0.0", + "@browser-logos/firefox": "^3.0.10", + "@browser-logos/safari": "^2.1.0", + "@graphql-codegen/cli": "^6.0.1", + "@graphql-codegen/client-preset": "^5.1.0", + "@graphql-codegen/typescript-msw": "^3.0.1", + "@storybook/addon-docs": "^9.1.13", + "@storybook/react-vite": "^9.1.13", + "@tanstack/react-query-devtools": "^5.90.2", + "@tanstack/react-router-devtools": "^1.131.44", + "@tanstack/router-plugin": "^1.131.44", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.9.1", + "@types/react": "19.2.2", + "@types/react-dom": "19.2.2", + "@types/swagger-ui-dist": "^3.30.6", + "@vitejs/plugin-react": "^5.0.4", +>>>>>>> v1.6.0 "@vitest/coverage-v8": "^3.2.4", "autoprefixer": "^10.4.21", "browserslist-to-esbuild": "^2.1.1", "graphql": "^16.11.0", - "happy-dom": "^18.0.1", + "happy-dom": "^20.0.4", "i18next-parser": "^9.3.0", +<<<<<<< HEAD "knip": "^5.63.1", "msw": "^2.11.2", +======= + "knip": "^5.64.2", + "msw": "^2.11.6", +>>>>>>> v1.6.0 "msw-storybook-addon": "^2.0.5", "postcss": "^8.5.6", "postcss-import": "^16.1.1", "postcss-nesting": "^13.0.2", "rimraf": "^6.0.1", "storybook": "^9.1.5", +<<<<<<< HEAD "storybook-react-i18next": "4.0.11", "tailwindcss": "^3.4.17", "typescript": "^5.9.2", "vite": "7.1.5", "vite-plugin-compression": "^0.5.1", "vite-plugin-graphql-codegen": "^3.6.3", +======= + "tailwindcss": "^3.4.18", + "typescript": "^5.9.3", + "vite": "7.1.11", + "vite-plugin-compression": "^0.5.1", + "vite-plugin-graphql-codegen": "^3.7.0", +>>>>>>> v1.6.0 "vite-plugin-manifest-sri": "^0.2.0", "vitest": "^3.2.4" } @@ -1036,9 +1090,15 @@ } }, "node_modules/@biomejs/biome": { +<<<<<<< HEAD "version": "2.2.4", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.2.4.tgz", "integrity": "sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg==", +======= + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.2.5.tgz", + "integrity": "sha512-zcIi+163Rc3HtyHbEO7CjeHq8DjQRs40HsGbW6vx2WI0tg8mYQOPouhvHSyEnCBAorfYNnKdR64/IxO7xQ5faw==", +>>>>>>> v1.6.0 "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -1052,6 +1112,7 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { +<<<<<<< HEAD "@biomejs/cli-darwin-arm64": "2.2.4", "@biomejs/cli-darwin-x64": "2.2.4", "@biomejs/cli-linux-arm64": "2.2.4", @@ -1066,6 +1127,22 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.4.tgz", "integrity": "sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA==", +======= + "@biomejs/cli-darwin-arm64": "2.2.5", + "@biomejs/cli-darwin-x64": "2.2.5", + "@biomejs/cli-linux-arm64": "2.2.5", + "@biomejs/cli-linux-arm64-musl": "2.2.5", + "@biomejs/cli-linux-x64": "2.2.5", + "@biomejs/cli-linux-x64-musl": "2.2.5", + "@biomejs/cli-win32-arm64": "2.2.5", + "@biomejs/cli-win32-x64": "2.2.5" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.5.tgz", + "integrity": "sha512-MYT+nZ38wEIWVcL5xLyOhYQQ7nlWD0b/4mgATW2c8dvq7R4OQjt/XGXFkXrmtWmQofaIM14L7V8qIz/M+bx5QQ==", +>>>>>>> v1.6.0 "cpu": [ "arm64" ], @@ -1080,9 +1157,15 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { +<<<<<<< HEAD "version": "2.2.4", "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.4.tgz", "integrity": "sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg==", +======= + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.5.tgz", + "integrity": "sha512-FLIEl73fv0R7dI10EnEiZLw+IMz3mWLnF95ASDI0kbx6DDLJjWxE5JxxBfmG+udz1hIDd3fr5wsuP7nwuTRdAg==", +>>>>>>> v1.6.0 "cpu": [ "x64" ], @@ -1097,9 +1180,15 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { +<<<<<<< HEAD "version": "2.2.4", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.4.tgz", "integrity": "sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw==", +======= + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.5.tgz", + "integrity": "sha512-5DjiiDfHqGgR2MS9D+AZ8kOfrzTGqLKywn8hoXpXXlJXIECGQ32t+gt/uiS2XyGBM2XQhR6ztUvbjZWeccFMoQ==", +>>>>>>> v1.6.0 "cpu": [ "arm64" ], @@ -1114,9 +1203,15 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { +<<<<<<< HEAD "version": "2.2.4", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.4.tgz", "integrity": "sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ==", +======= + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.5.tgz", + "integrity": "sha512-5Ov2wgAFwqDvQiESnu7b9ufD1faRa+40uwrohgBopeY84El2TnBDoMNXx6iuQdreoFGjwW8vH6k68G21EpNERw==", +>>>>>>> v1.6.0 "cpu": [ "arm64" ], @@ -1131,9 +1226,15 @@ } }, "node_modules/@biomejs/cli-linux-x64": { +<<<<<<< HEAD "version": "2.2.4", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.4.tgz", "integrity": "sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ==", +======= + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.5.tgz", + "integrity": "sha512-fq9meKm1AEXeAWan3uCg6XSP5ObA6F/Ovm89TwaMiy1DNIwdgxPkNwxlXJX8iM6oRbFysYeGnT0OG8diCWb9ew==", +>>>>>>> v1.6.0 "cpu": [ "x64" ], @@ -1148,9 +1249,15 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { +<<<<<<< HEAD "version": "2.2.4", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.4.tgz", "integrity": "sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg==", +======= + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.5.tgz", + "integrity": "sha512-AVqLCDb/6K7aPNIcxHaTQj01sl1m989CJIQFQEaiQkGr2EQwyOpaATJ473h+nXDUuAcREhccfRpe/tu+0wu0eQ==", +>>>>>>> v1.6.0 "cpu": [ "x64" ], @@ -1165,9 +1272,15 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { +<<<<<<< HEAD "version": "2.2.4", "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.4.tgz", "integrity": "sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ==", +======= + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.5.tgz", + "integrity": "sha512-xaOIad4wBambwJa6mdp1FigYSIF9i7PCqRbvBqtIi9y29QtPVQ13sDGtUnsRoe6SjL10auMzQ6YAe+B3RpZXVg==", +>>>>>>> v1.6.0 "cpu": [ "arm64" ], @@ -1182,9 +1295,15 @@ } }, "node_modules/@biomejs/cli-win32-x64": { +<<<<<<< HEAD "version": "2.2.4", "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.4.tgz", "integrity": "sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg==", +======= + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.5.tgz", + "integrity": "sha512-F/jhuXCssPFAuciMhHKk00xnCAxJRS/pUzVfXYmOMUp//XW7mO6QeCjsjvnm8L4AO/dG2VOB0O+fJPiJ2uXtIw==", +>>>>>>> v1.6.0 "cpu": [ "x64" ], @@ -1216,6 +1335,7 @@ "integrity": "sha512-diidPiK62E4hlAh0dyLfWQDZXi2SSAGiOuw6iqD1x8ztw7L/Sz3He46FhcxEzYa1hKi1blCkjnKDjqw6rQfgcA==", "dev": true }, +<<<<<<< HEAD "node_modules/@bundled-es-modules/cookie": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", @@ -1236,6 +1356,8 @@ "statuses": "^2.0.1" } }, +======= +>>>>>>> v1.6.0 "node_modules/@csstools/selector-resolve-nested": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-3.1.0.tgz", @@ -1863,18 +1985,30 @@ "license": "MIT" }, "node_modules/@fontsource/inconsolata": { +<<<<<<< HEAD "version": "5.2.7", "resolved": "https://registry.npmjs.org/@fontsource/inconsolata/-/inconsolata-5.2.7.tgz", "integrity": "sha512-qmARxA7lS16PCoB404sehiXzh8mzcZzFio6n05/zpxIC97W+AxdJqgWQ0kfMzdj78ILy2PaaXZ1Js4kfxL1JMw==", +======= + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/inconsolata/-/inconsolata-5.2.8.tgz", + "integrity": "sha512-lIZW+WOZYpUH91g9r6rYYhfTmptF3YPPM54ZOs8IYVeeL4SeiAu4tfj7mdr8llYEq31DLYgi6JtGIJa192gB0Q==", +>>>>>>> v1.6.0 "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" } }, "node_modules/@fontsource/inter": { +<<<<<<< HEAD "version": "5.2.7", "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.7.tgz", "integrity": "sha512-sfE4VQglV9B05DuJ+RD2RHY42FiVTdCf/dJtVkugRVkygoDigLM4SuNcBrCeo+zoGiC/IudmVcE6QKqW7S1Gkg==", +======= + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", +>>>>>>> v1.6.0 "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -1893,15 +2027,18 @@ } }, "node_modules/@graphql-codegen/add": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-5.0.3.tgz", - "integrity": "sha512-SxXPmramkth8XtBlAHu4H4jYcYXM/o3p01+psU+0NADQowA8jtYkK6MW5rV6T+CxkEaNZItfSmZRPgIuypcqnA==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-6.0.0.tgz", + "integrity": "sha512-biFdaURX0KTwEJPQ1wkT6BRgNasqgQ5KbCI1a3zwtLtO7XTo7/vKITPylmiU27K5DSOWYnY/1jfSqUAEBuhZrQ==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.0.3", + "@graphql-codegen/plugin-helpers": "^6.0.0", "tslib": "~2.6.0" }, + "engines": { + "node": ">=16" + }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } @@ -1914,18 +2051,18 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/cli": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-5.0.7.tgz", - "integrity": "sha512-h/sxYvSaWtxZxo8GtaA8SvcHTyViaaPd7dweF/hmRDpaQU1o3iU3EZxlcJ+oLTunU0tSMFsnrIXm/mhXxI11Cw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-6.0.1.tgz", + "integrity": "sha512-6iP91joxb7phdicDrIF8Cv9ah2QpPVXUUu7rbOaQKvqey+QKYmHcxGCi9r5/7p4lUiHZPQvfB7xDHURHQca1SA==", "dev": true, "license": "MIT", "dependencies": { "@babel/generator": "^7.18.13", "@babel/template": "^7.18.10", "@babel/types": "^7.18.13", - "@graphql-codegen/client-preset": "^4.8.2", - "@graphql-codegen/core": "^4.0.2", - "@graphql-codegen/plugin-helpers": "^5.1.1", + "@graphql-codegen/client-preset": "^5.0.0", + "@graphql-codegen/core": "^5.0.0", + "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-tools/apollo-engine-loader": "^8.0.0", "@graphql-tools/code-file-loader": "^8.0.0", "@graphql-tools/git-loader": "^8.0.0", @@ -1933,20 +2070,19 @@ "@graphql-tools/graphql-file-loader": "^8.0.0", "@graphql-tools/json-file-loader": "^8.0.0", "@graphql-tools/load": "^8.1.0", - "@graphql-tools/prisma-loader": "^8.0.0", "@graphql-tools/url-loader": "^8.0.0", "@graphql-tools/utils": "^10.0.0", + "@inquirer/prompts": "^7.8.2", "@whatwg-node/fetch": "^0.10.0", "chalk": "^4.1.0", - "cosmiconfig": "^8.1.3", - "debounce": "^1.2.0", + "cosmiconfig": "^9.0.0", + "debounce": "^2.0.0", "detect-indent": "^6.0.0", "graphql-config": "^5.1.1", - "inquirer": "^8.0.0", "is-glob": "^4.0.1", - "jiti": "^1.17.1", + "jiti": "^2.3.0", "json-to-pretty-yaml": "^1.2.2", - "listr2": "^4.0.5", + "listr2": "^9.0.0", "log-symbols": "^4.0.0", "micromatch": "^4.0.5", "shell-quote": "^1.7.3", @@ -1975,22 +2111,62 @@ } } }, + "node_modules/@graphql-codegen/cli/node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "license": "MIT", +<<<<<<< HEAD +======= + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@graphql-codegen/cli/node_modules/jiti": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/@graphql-codegen/client-preset": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-4.8.3.tgz", - "integrity": "sha512-QpEsPSO9fnRxA6Z66AmBuGcwHjZ6dYSxYo5ycMlYgSPzAbyG8gn/kWljofjJfWqSY+T/lRn+r8IXTH14ml24vQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-5.1.0.tgz", + "integrity": "sha512-MYMy9dIlAgT3q1U8WUys6Y8yt/T9WLsm1DczRtrCpV5N11v4Rlg3hGWQmEvhJtBbWxgzfYoHZHb0TohtbLkJ+g==", "dev": true, "license": "MIT", +>>>>>>> v1.6.0 "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/template": "^7.20.7", - "@graphql-codegen/add": "^5.0.3", - "@graphql-codegen/gql-tag-operations": "4.0.17", - "@graphql-codegen/plugin-helpers": "^5.1.1", - "@graphql-codegen/typed-document-node": "^5.1.2", - "@graphql-codegen/typescript": "^4.1.6", - "@graphql-codegen/typescript-operations": "^4.6.1", - "@graphql-codegen/visitor-plugin-common": "^5.8.0", + "@graphql-codegen/add": "^6.0.0", + "@graphql-codegen/gql-tag-operations": "5.0.2", + "@graphql-codegen/plugin-helpers": "^6.0.0", + "@graphql-codegen/typed-document-node": "^6.0.2", + "@graphql-codegen/typescript": "^5.0.2", + "@graphql-codegen/typescript-operations": "^5.0.2", + "@graphql-codegen/visitor-plugin-common": "^6.1.0", "@graphql-tools/documents": "^1.0.0", "@graphql-tools/utils": "^10.0.0", "@graphql-typed-document-node/core": "3.2.0", @@ -2017,17 +2193,20 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/core": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@graphql-codegen/core/-/core-4.0.2.tgz", - "integrity": "sha512-IZbpkhwVqgizcjNiaVzNAzm/xbWT6YnGgeOLwVjm4KbJn3V2jchVtuzHH09G5/WkkLSk2wgbXNdwjM41JxO6Eg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/core/-/core-5.0.0.tgz", + "integrity": "sha512-vLTEW0m8LbE4xgRwbFwCdYxVkJ1dBlVJbQyLb9Q7bHnVFgHAP982Xo8Uv7FuPBmON+2IbTjkCqhFLHVZbqpvjQ==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.0.3", + "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-tools/schema": "^10.0.0", "@graphql-tools/utils": "^10.0.0", "tslib": "~2.6.0" }, + "engines": { + "node": ">=16" + }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } @@ -2040,14 +2219,14 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/gql-tag-operations": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-4.0.17.tgz", - "integrity": "sha512-2pnvPdIG6W9OuxkrEZ6hvZd142+O3B13lvhrZ48yyEBh2ujtmKokw0eTwDHtlXUqjVS0I3q7+HB2y12G/m69CA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-5.0.2.tgz", + "integrity": "sha512-iK+LFGv4ihHKeerADFPTL7Iq4iNr+J1jm2+GUMtwTSAL4nGk+BdfyruV7eR53R7Des8NFdI+9hBzKbbob7VwGQ==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.1.0", - "@graphql-codegen/visitor-plugin-common": "5.8.0", + "@graphql-codegen/plugin-helpers": "^6.0.0", + "@graphql-codegen/visitor-plugin-common": "6.1.0", "@graphql-tools/utils": "^10.0.0", "auto-bind": "~4.0.0", "tslib": "~2.6.0" @@ -2067,9 +2246,9 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/plugin-helpers": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-5.1.1.tgz", - "integrity": "sha512-28GHODK2HY1NhdyRcPP3sCz0Kqxyfiz7boIZ8qIxFYmpLYnlDgiYok5fhFLVSZihyOpCs4Fa37gVHf/Q4I2FEg==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-6.0.0.tgz", + "integrity": "sha512-Z7P89vViJvQakRyMbq/JF2iPLruRFOwOB6IXsuSvV/BptuuEd7fsGPuEf8bdjjDxUY0pJZnFN8oC7jIQ8p9GKA==", "dev": true, "license": "MIT", "dependencies": { @@ -2095,16 +2274,19 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/schema-ast": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/schema-ast/-/schema-ast-4.1.0.tgz", - "integrity": "sha512-kZVn0z+th9SvqxfKYgztA6PM7mhnSZaj4fiuBWvMTqA+QqQ9BBed6Pz41KuD/jr0gJtnlr2A4++/0VlpVbCTmQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/schema-ast/-/schema-ast-5.0.0.tgz", + "integrity": "sha512-jn7Q3PKQc0FxXjbpo9trxzlz/GSFQWxL042l0iC8iSbM/Ar+M7uyBwMtXPsev/3Razk+osQyreghIz0d2+6F7Q==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.0.3", + "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-tools/utils": "^10.0.0", "tslib": "~2.6.0" }, + "engines": { + "node": ">=16" + }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } @@ -2117,14 +2299,14 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/typed-document-node": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-5.1.2.tgz", - "integrity": "sha512-jaxfViDqFRbNQmfKwUY8hDyjnLTw2Z7DhGutxoOiiAI0gE/LfPe0LYaVFKVmVOOD7M3bWxoWfu4slrkbWbUbEw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-6.0.2.tgz", + "integrity": "sha512-nqcD23F87jLPQ1P2jJaepNAa4SY8Xy2soacPyQMwvxWtbRSXlg/LBUjtbEkCaU2SuLoa4L3w8VPuGoQ3EWUzeg==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.1.0", - "@graphql-codegen/visitor-plugin-common": "5.8.0", + "@graphql-codegen/plugin-helpers": "^6.0.0", + "@graphql-codegen/visitor-plugin-common": "6.1.0", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", "tslib": "~2.6.0" @@ -2144,15 +2326,15 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/typescript": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-4.1.6.tgz", - "integrity": "sha512-vpw3sfwf9A7S+kIUjyFxuvrywGxd4lmwmyYnnDVjVE4kSQ6Td3DpqaPTy8aNQ6O96vFoi/bxbZS2BW49PwSUUA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-5.0.2.tgz", + "integrity": "sha512-OJYXpS9SRf4VFzqu3ZH/RmTftGhAVTCmscH63iPlvTlCT8NBmpSHdZ875AEa38LugdL8XgUcGsI3pprP3e5j/w==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.1.0", - "@graphql-codegen/schema-ast": "^4.0.2", - "@graphql-codegen/visitor-plugin-common": "5.8.0", + "@graphql-codegen/plugin-helpers": "^6.0.0", + "@graphql-codegen/schema-ast": "^5.0.0", + "@graphql-codegen/visitor-plugin-common": "6.1.0", "auto-bind": "~4.0.0", "tslib": "~2.6.0" }, @@ -2481,15 +2663,15 @@ } }, "node_modules/@graphql-codegen/typescript-operations": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-4.6.1.tgz", - "integrity": "sha512-k92laxhih7s0WZ8j5WMIbgKwhe64C0As6x+PdcvgZFMudDJ7rPJ/hFqJ9DCRxNjXoHmSjnr6VUuQZq4lT1RzCA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-5.0.2.tgz", + "integrity": "sha512-i2nSJ5a65H+JgXwWvEuYehVYUImIvrHk3PTs+Fcj+OjZFvDl2qBziIhr6shCjV0KH9IZ6Y+1v4TzkxZr/+XFjA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.1.0", - "@graphql-codegen/typescript": "^4.1.6", - "@graphql-codegen/visitor-plugin-common": "5.8.0", + "@graphql-codegen/plugin-helpers": "^6.0.0", + "@graphql-codegen/typescript": "^5.0.2", + "@graphql-codegen/visitor-plugin-common": "6.1.0", "auto-bind": "~4.0.0", "tslib": "~2.6.0" }, @@ -2521,19 +2703,19 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/visitor-plugin-common": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-5.8.0.tgz", - "integrity": "sha512-lC1E1Kmuzi3WZUlYlqB4fP6+CvbKH9J+haU1iWmgsBx5/sO2ROeXJG4Dmt8gP03bI2BwjiwV5WxCEMlyeuzLnA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-6.1.0.tgz", + "integrity": "sha512-AvGO1pe+b/kAa7+WBDlNDXOruRZWv/NnhLHgTggiW2XWRv33biuzg4cF1UTdpR2jmESZzJU4kXngLLX8RYJWLA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^5.1.0", + "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-tools/optimize": "^2.0.0", "@graphql-tools/relay-operation-optimizer": "^7.0.0", "@graphql-tools/utils": "^10.0.0", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", - "dependency-graph": "^0.11.0", + "dependency-graph": "^1.0.0", "graphql-tag": "^2.11.0", "parse-filepath": "^1.0.2", "tslib": "~2.6.0" @@ -2545,6 +2727,16 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, + "node_modules/@graphql-codegen/visitor-plugin-common/node_modules/dependency-graph": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-1.0.0.tgz", + "integrity": "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@graphql-codegen/visitor-plugin-common/node_modules/tslib": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", @@ -2957,37 +3149,6 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@graphql-tools/prisma-loader": { - "version": "8.0.17", - "resolved": "https://registry.npmjs.org/@graphql-tools/prisma-loader/-/prisma-loader-8.0.17.tgz", - "integrity": "sha512-fnuTLeQhqRbA156pAyzJYN0KxCjKYRU5bz1q/SKOwElSnAU4k7/G1kyVsWLh7fneY78LoMNH5n+KlFV8iQlnyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-tools/url-loader": "^8.0.15", - "@graphql-tools/utils": "^10.5.6", - "@types/js-yaml": "^4.0.0", - "@whatwg-node/fetch": "^0.10.0", - "chalk": "^4.1.0", - "debug": "^4.3.1", - "dotenv": "^16.0.0", - "graphql-request": "^6.0.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "jose": "^5.0.0", - "js-yaml": "^4.0.0", - "lodash": "^4.17.20", - "scuid": "^1.1.0", - "tslib": "^2.4.0", - "yaml-ast-parser": "^0.0.43" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, "node_modules/@graphql-tools/relay-operation-optimizer": { "version": "7.0.21", "resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.0.21.tgz", @@ -3114,6 +3275,7 @@ "node": ">=10.13.0" } }, +<<<<<<< HEAD "node_modules/@inquirer/confirm": { "version": "5.1.16", "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.16.tgz", @@ -3122,6 +3284,51 @@ "license": "MIT", "dependencies": { "@inquirer/core": "^10.2.0", +======= + "node_modules/@inquirer/ansi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.0.tgz", + "integrity": "sha512-JWaTfCxI1eTmJ1BIv86vUfjVatOdxwD0DAVKYevY8SazeUUZtW+tNbsdejVO1GYE0GXJW1N1ahmiC3TFd+7wZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.2.4.tgz", + "integrity": "sha512-2n9Vgf4HSciFq8ttKXk+qy+GsyTXPV1An6QAwe/8bkbbqvG4VW1I/ZY1pNu2rf+h9bdzMLPbRSfcNxkHBy/Ydw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.0", + "@inquirer/core": "^10.2.2", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.18", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.18.tgz", + "integrity": "sha512-MilmWOzHa3Ks11tzvuAmFoAd/wRuaP3SwlT1IZhyMke31FKLxPiuDWcGXhU+PKveNOpAc4axzAgrgxuIJJRmLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.2", +>>>>>>> v1.6.0 "@inquirer/type": "^3.0.8" }, "engines": { @@ -3137,6 +3344,7 @@ } }, "node_modules/@inquirer/core": { +<<<<<<< HEAD "version": "10.2.0", "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.2.0.tgz", "integrity": "sha512-NyDSjPqhSvpZEMZrLCYUquWNl+XC/moEcVFqS55IEYIYsY0a1cUCevSqk7ctOlnm/RaSBU5psFryNlxcmGrjaA==", @@ -3146,6 +3354,17 @@ "@inquirer/figures": "^1.0.13", "@inquirer/type": "^3.0.8", "ansi-escapes": "^4.3.2", +======= + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.2.2.tgz", + "integrity": "sha512-yXq/4QUnk4sHMtmbd7irwiepjB8jXU0kkFRL4nr/aDBA2mDz13cMakEWdDwX3eSCTkk03kwcndD1zfRAIlELxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.0", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", +>>>>>>> v1.6.0 "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", @@ -3164,6 +3383,7 @@ } } }, +<<<<<<< HEAD "node_modules/@inquirer/core/node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -3188,11 +3408,18 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.1.tgz", "integrity": "sha512-Oau4yL24d2B5IL4ma4UpbQigkVhzPDXLoqy1ggK4gnHg/stmkffJE4oOXHXF3uz0UEpywG68KcyXsyYpA1Re/Q==", +======= + "node_modules/@inquirer/editor": { + "version": "4.2.20", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.20.tgz", + "integrity": "sha512-7omh5y5bK672Q+Brk4HBbnHNowOZwrb/78IFXdrEB9PfdxL3GudQyDk8O9vQ188wj3xrEebS2M9n18BjJoI83g==", +>>>>>>> v1.6.0 "dev": true, "license": "MIT", "dependencies": { - "chardet": "^2.1.0", - "iconv-lite": "^0.6.3" + "@inquirer/core": "^10.2.2", + "@inquirer/external-editor": "^1.0.2", + "@inquirer/type": "^3.0.8" }, "engines": { "node": ">=18" @@ -3204,24 +3431,43 @@ "@types/node": { "optional": true } +<<<<<<< HEAD +======= } }, - "node_modules/@inquirer/figures": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.13.tgz", - "integrity": "sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==", + "node_modules/@inquirer/expand": { + "version": "4.0.20", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.20.tgz", + "integrity": "sha512-Dt9S+6qUg94fEvgn54F2Syf0Z3U8xmnBI9ATq2f5h9xt09fs2IJXSCIXyyVHwvggKWFXEY/7jATRo2K6Dkn6Ow==", "dev": true, "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.2", + "@inquirer/type": "^3.0.8", + "yoctocolors-cjs": "^2.1.2" + }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@inquirer/type": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.8.tgz", - "integrity": "sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==", + "node_modules/@inquirer/external-editor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.2.tgz", + "integrity": "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==", "dev": true, "license": "MIT", + "dependencies": { + "chardet": "^2.1.0", + "iconv-lite": "^0.7.0" + }, "engines": { "node": ">=18" }, @@ -3234,93 +3480,308 @@ } } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "dev": true, "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, "engines": { - "node": "20 || >=22" + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" +>>>>>>> v1.6.0 } }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "node_modules/@inquirer/figures": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.13.tgz", + "integrity": "sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==", "dev": true, "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, "engines": { - "node": "20 || >=22" + "node": ">=18" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "node_modules/@inquirer/input": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.2.4.tgz", + "integrity": "sha512-cwSGpLBMwpwcZZsc6s1gThm0J+it/KIJ+1qFL2euLmSKUMGumJ5TcbMgxEjMjNHRGadouIYbiIgruKoDZk7klw==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + "@inquirer/core": "^10.2.2", + "@inquirer/type": "^3.0.8" }, "engines": { - "node": ">=12" + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/@inquirer/number": { + "version": "3.0.20", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.20.tgz", + "integrity": "sha512-bbooay64VD1Z6uMfNehED2A2YOPHSJnQLs9/4WNiV/EK+vXczf/R988itL2XLDGTgmhMF2KkiWZo+iEZmc4jqg==", "dev": true, "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.2", + "@inquirer/type": "^3.0.8" + }, "engines": { - "node": ">=12" + "node": ">=18" }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/@inquirer/password": { + "version": "4.0.20", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.20.tgz", + "integrity": "sha512-nxSaPV2cPvvoOmRygQR+h0B+Av73B01cqYLcr7NXcGXhbmsYfUb8fDdw2Us1bI2YsX+VvY7I7upgFYsyf8+Nug==", "dev": true, "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.0", + "@inquirer/core": "^10.2.2", + "@inquirer/type": "^3.0.8" + }, "engines": { - "node": ">=12" + "node": ">=18" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "node_modules/@inquirer/prompts": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.8.6.tgz", + "integrity": "sha512-68JhkiojicX9SBUD8FE/pSKbOKtwoyaVj1kwqLfvjlVXZvOy3iaSWX4dCLsZyYx/5Ur07Fq+yuDNOen+5ce6ig==", "dev": true, "license": "MIT", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "@inquirer/checkbox": "^4.2.4", + "@inquirer/confirm": "^5.1.18", + "@inquirer/editor": "^4.2.20", + "@inquirer/expand": "^4.0.20", + "@inquirer/input": "^4.2.4", + "@inquirer/number": "^3.0.20", + "@inquirer/password": "^4.0.20", + "@inquirer/rawlist": "^4.1.8", + "@inquirer/search": "^3.1.3", + "@inquirer/select": "^4.3.4" }, "engines": { - "node": ">=12" + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.8.tgz", + "integrity": "sha512-CQ2VkIASbgI2PxdzlkeeieLRmniaUU1Aoi5ggEdm6BIyqopE9GuDXdDOj9XiwOqK5qm72oI2i6J+Gnjaa26ejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.2", + "@inquirer/type": "^3.0.8", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.1.3.tgz", + "integrity": "sha512-D5T6ioybJJH0IiSUK/JXcoRrrm8sXwzrVMjibuPs+AgxmogKslaafy1oxFiorNI4s3ElSkeQZbhYQgLqiL8h6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.2", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.3.4.tgz", + "integrity": "sha512-Qp20nySRmfbuJBBsgPU7E/cL62Hf250vMZRzYDcBHty2zdD1kKCnoDFWRr0WO2ZzaXp3R7a4esaVGJUx0E6zvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.0", + "@inquirer/core": "^10.2.2", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.8.tgz", + "integrity": "sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -3460,9 +3921,15 @@ } }, "node_modules/@mswjs/interceptors": { +<<<<<<< HEAD "version": "0.39.6", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.6.tgz", "integrity": "sha512-bndDP83naYYkfayr/qhBHMhk0YGwS1iv6vaEGcr0SQbO0IZtbOPqjKjds/WcG+bJA+1T5vCx6kprKOzn5Bg+Vw==", +======= + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz", + "integrity": "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==", +>>>>>>> v1.6.0 "dev": true, "license": "MIT", "dependencies": { @@ -3478,16 +3945,26 @@ } }, "node_modules/@napi-rs/wasm-runtime": { +<<<<<<< HEAD "version": "1.0.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.4.tgz", "integrity": "sha512-+ZEtJPp8EF8h4kN6rLQECRor00H7jtDgBVtttIUoxuDkXLiQMaSBqju3LV/IEsMvqVG5pviUvR4jYhIA1xNm8w==", +======= + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.5.tgz", + "integrity": "sha512-TBr9Cf9onSAS2LQ2+QHx6XcC6h9+RIzJgbqG3++9TUZSH204AwEy5jg3BTQ0VATsyoGj4ee49tN/y6rvaOOtcg==", +>>>>>>> v1.6.0 "dev": true, "license": "MIT", "optional": true, "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", +<<<<<<< HEAD "@tybys/wasm-util": "^0.10.0" +======= + "@tybys/wasm-util": "^0.10.1" +>>>>>>> v1.6.0 } }, "node_modules/@nodelib/fs.scandir": { @@ -3554,9 +4031,15 @@ "license": "MIT" }, "node_modules/@oxc-resolver/binding-android-arm-eabi": { +<<<<<<< HEAD "version": "11.7.2", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.7.2.tgz", "integrity": "sha512-ITflrd9l5pFPXW10w1gOGJqmyeO6LTO/yiXb3st4Uqr6bcPxCdsXZXAZop3QsSeE8DjjfGXv3Ws+Fb2KmYeCrA==", +======= + "version": "11.8.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.8.4.tgz", + "integrity": "sha512-6BjMji0TcvQfJ4EoSunOSyu/SiyHKficBD0V3Y0NxF0beaNnnZ7GYEi2lHmRNnRCuIPK8IuVqQ6XizYau+CkKw==", +>>>>>>> v1.6.0 "cpu": [ "arm" ], @@ -3568,9 +4051,15 @@ ] }, "node_modules/@oxc-resolver/binding-android-arm64": { +<<<<<<< HEAD "version": "11.7.2", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.7.2.tgz", "integrity": "sha512-mjEqCGOZHBpIkjSskW0jkhhVSnaREMmXYW5oDaJKBx86kFSiufEjo8duLTwjRekQ0JlwlEtWiXA759eO4TJ7/w==", +======= + "version": "11.8.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.8.4.tgz", + "integrity": "sha512-SxF4X6rzCBS9XNPXKZGoIHIABjfGmtQpEgRBDzpDHx5VTuLAUmwLTHXnVBAZoX5bmnhF79RiMElavzFdJ2cA1A==", +>>>>>>> v1.6.0 "cpu": [ "arm64" ], @@ -3582,9 +4071,15 @@ ] }, "node_modules/@oxc-resolver/binding-darwin-arm64": { +<<<<<<< HEAD "version": "11.7.2", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.7.2.tgz", "integrity": "sha512-sXgElUiNredwvWshUXKL7RbBr6ovSthg3fCTQViY8/jfWKnDRKhUFZiCwABma0CWXC1X2Ij6EkZj40cufRM0bA==", +======= + "version": "11.8.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.8.4.tgz", + "integrity": "sha512-8zWeERrzgscAniE6kh1TQ4E7GJyglYsvdoKrHYLBCbHWD+0/soffiwAYxZuckKEQSc2RXMSPjcu+JTCALaY0Dw==", +>>>>>>> v1.6.0 "cpu": [ "arm64" ], @@ -3596,9 +4091,15 @@ ] }, "node_modules/@oxc-resolver/binding-darwin-x64": { +<<<<<<< HEAD "version": "11.7.2", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.7.2.tgz", "integrity": "sha512-EOqYSn1+L5KsShn5lZ303eU9MqjxHNzA7GOHthIcVXfCPtJ+zL89wXh25F+J7mSwiDilp444+rR1hc5Lh+eEWg==", +======= + "version": "11.8.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.8.4.tgz", + "integrity": "sha512-BUwggKz8Hi5uEQ0AeVTSun1+sp4lzNcItn+L7fDsHu5Cx0Zueuo10BtVm+dIwmYVVPL5oGYOeD0fS7MKAazKiw==", +>>>>>>> v1.6.0 "cpu": [ "x64" ], @@ -3610,9 +4111,15 @@ ] }, "node_modules/@oxc-resolver/binding-freebsd-x64": { +<<<<<<< HEAD "version": "11.7.2", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.7.2.tgz", "integrity": "sha512-Dyvdj++qc5ANVN3JzqJVAlb+IMUtYLPyLaiPFW4+JfvAQFf/iYkpFQv7maeXhhR+GK3rI+PUQXP2HSIiPsClRg==", +======= + "version": "11.8.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.8.4.tgz", + "integrity": "sha512-fPO5TQhnn8gA6yP4o49lc4Gn8KeDwAp9uYd4PlE3Q00JVqU6cY9WecDhYHrWtiFcyoZ8UVBlIxuhRqT/DP4Z4A==", +>>>>>>> v1.6.0 "cpu": [ "x64" ], @@ -3624,9 +4131,15 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { +<<<<<<< HEAD "version": "11.7.2", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.7.2.tgz", "integrity": "sha512-wUSx/QqggWowrAiyTSci5YUdHvRFpeBbCn2pUwT8XwDoSY2CBuMYR5qzm68ijjzmrv/XyMhl9HxBLy8/UbczWQ==", +======= + "version": "11.8.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.8.4.tgz", + "integrity": "sha512-QuNbdUaVGiP0W0GrXsvCDZjqeL4lZGU7aXlx/S2tCvyTk3wh6skoiLJgqUf/eeqXfUPnzTfntYqyfolzCAyBYA==", +>>>>>>> v1.6.0 "cpu": [ "arm" ], @@ -3638,9 +4151,15 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { +<<<<<<< HEAD "version": "11.7.2", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.7.2.tgz", "integrity": "sha512-6w91XhCno0OMqv+UqiuMahasl87Ae8sdSSEFBLF2ic+ySZg+BPpFO5VYUBtdSFJ6gWy7R66JudB5HUJpMbMZlA==", +======= + "version": "11.8.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.8.4.tgz", + "integrity": "sha512-p/zLMfza8OsC4BDKxqeZ9Qel+4eA/oiMSyKLRkMrTgt6OWQq1d5nHntjfG35Abcw4ev6Q9lRU3NOW5hj0xlUbw==", +>>>>>>> v1.6.0 "cpu": [ "arm" ], @@ -3652,9 +4171,15 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { +<<<<<<< HEAD "version": "11.7.2", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.7.2.tgz", "integrity": "sha512-S2FQ4cYK7JgmTCy0ay5UIUiRTrQdtKUSaAoC+En9yqaoZwHxcQy9HJ53k5jiAPIJnDR0NgAaOl3q11PUxd58XQ==", +======= + "version": "11.8.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.8.4.tgz", + "integrity": "sha512-bvJF9wWxF1+a5YZATlS5JojpOMC7OsnTatA6sXVHoOb7MIigjledYB5ZMAeRrnWWexRMiEX3YSaA46oSfOzmOg==", +>>>>>>> v1.6.0 "cpu": [ "arm64" ], @@ -3666,9 +4191,15 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm64-musl": { +<<<<<<< HEAD "version": "11.7.2", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.7.2.tgz", "integrity": "sha512-4Dq8KAJZ4RNe7uSISsoP2/O7fc/rZWqxgkch/5eqa0N0gHMrHd9moGzvdV9Hi9oRSnuTmHzRQTqy02S5L3Rc/g==", +======= + "version": "11.8.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.8.4.tgz", + "integrity": "sha512-gf4nwGBfu+EFwOn5p7/T7VF4jmIdfodwJS9MRkOBHvuAm3LQgCX7O6d3Y80mm0TV7ZMRD/trfW628rHfd5++vQ==", +>>>>>>> v1.6.0 "cpu": [ "arm64" ], @@ -3680,9 +4211,15 @@ ] }, "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { +<<<<<<< HEAD "version": "11.7.2", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.7.2.tgz", "integrity": "sha512-/w0wJkrtcjvPUNthhmhbG269ySFgxr/DQCYzhBxICKWbiafmNvJTnmYGtEZKoI+wwnukFL8TT7LWbu7hzdp7mw==", +======= + "version": "11.8.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.8.4.tgz", + "integrity": "sha512-T120R5GIzRd41rYWWKCI6cSYrZjmRQzf3X4xeE1WX396Uabz5DX8KU7RnVHihSK+KDxccCVOFBxcH3ITd+IEpw==", +>>>>>>> v1.6.0 "cpu": [ "ppc64" ], @@ -3694,9 +4231,15 @@ ] }, "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { +<<<<<<< HEAD "version": "11.7.2", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.7.2.tgz", "integrity": "sha512-sFg880S4QCzBw4yqgPDi48sAxGT1iRW6Gd+C/FW2WYXsDK7dnHgWQ8f6Rp509fHGkPAe+G2ZypjrgPhZP4Btew==", +======= + "version": "11.8.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.8.4.tgz", + "integrity": "sha512-PVG7SxBFFjAaQ76p9O/0Xt5mTBlziRwpck+6cRNhy/hbWY/hSt8BFfPqw0EDSfnl40Uuh+NPsHFMnaWWyxbQEg==", +>>>>>>> v1.6.0 "cpu": [ "riscv64" ], @@ -3708,9 +4251,15 @@ ] }, "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { +<<<<<<< HEAD "version": "11.7.2", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.7.2.tgz", "integrity": "sha512-dypXqqwA67fVVpVUedpmHNEYn5vRe/y6zoAvDTfy7Se8QIbkeRvrp1EOL+Q8tfxMM72tdMxgOrfyvJ5SPRgy9Q==", +======= + "version": "11.8.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.8.4.tgz", + "integrity": "sha512-L0OklUhM2qLGaKvPSyKmwWpoijfc++VJtPyVgz031ShOXyo0WjD0ZGzusyJMsA1a/gdulAmN6CQ/0Sf4LGXEcw==", +>>>>>>> v1.6.0 "cpu": [ "riscv64" ], @@ -3722,9 +4271,15 @@ ] }, "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { +<<<<<<< HEAD "version": "11.7.2", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.7.2.tgz", "integrity": "sha512-aYDSyViNixd3YpUNcPvfhxAYUiBIPNXfVriTTHEz1ftNg+PglYrOZl5IAssj9uveO6pn2PpNOp/zAezeTtlwmA==", +======= + "version": "11.8.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.8.4.tgz", + "integrity": "sha512-18Ajz5hqO4cRGuoHzLFUsIPod9GIaIRDiXFg2m6CS3NgVdHx7iCZscplYH7KtjdE42M8nGWYMyyq5BOk7QVgPw==", +>>>>>>> v1.6.0 "cpu": [ "s390x" ], @@ -3736,9 +4291,15 @@ ] }, "node_modules/@oxc-resolver/binding-linux-x64-gnu": { +<<<<<<< HEAD "version": "11.7.2", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.7.2.tgz", "integrity": "sha512-/f5rmPZYeD2/d/siP6wvGGOQsupl074qtvPfSteQnWLIM5lWuUDa/53atjYMJHRHFhfQ7b4B3l84TaO8lszAkA==", +======= + "version": "11.8.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.8.4.tgz", + "integrity": "sha512-uHvH4RyYBdQ/lFGV9H+R1ScHg6EBnAhE3mnX+u+mO/btnalvg7j80okuHf8Qw0tLQiP5P1sEBoVeE6zviXY9IA==", +>>>>>>> v1.6.0 "cpu": [ "x64" ], @@ -3750,9 +4311,15 @@ ] }, "node_modules/@oxc-resolver/binding-linux-x64-musl": { +<<<<<<< HEAD "version": "11.7.2", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.7.2.tgz", "integrity": "sha512-5x9CGGTZfGWtemVnkNu4ZjqH4X9Oy+Ovm4wSlQTiKgpwCrSDjj0s4tITqiMif0mkWgoErxpdzfD8+hKQkOIgtw==", +======= + "version": "11.8.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.8.4.tgz", + "integrity": "sha512-X5z44qh5DdJfVhcqXAQFTDFUpcxdpf6DT/lHL5CFcdQGIZxatjc7gFUy05IXPI9xwfq39RValjJBvFovUk9XBw==", +>>>>>>> v1.6.0 "cpu": [ "x64" ], @@ -3764,9 +4331,15 @@ ] }, "node_modules/@oxc-resolver/binding-wasm32-wasi": { +<<<<<<< HEAD "version": "11.7.2", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.7.2.tgz", "integrity": "sha512-UlUxMVChYfi8nmuT9h9I7rQOfini6b40Ud4zYSeel5Qk8GvUT6eysVXAb+AUCJHMnuFCo6jgGqtXYb3yB5CWEQ==", +======= + "version": "11.8.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.8.4.tgz", + "integrity": "sha512-z3906y+cd8RRhBGNwHRrRAFxnKjXsBeL3+rdQjZpBrUyrhhsaV5iKD/ROx64FNJ9GjL/9mfon8A5xx/McYIqHA==", +>>>>>>> v1.6.0 "cpu": [ "wasm32" ], @@ -3774,16 +4347,26 @@ "license": "MIT", "optional": true, "dependencies": { +<<<<<<< HEAD "@napi-rs/wasm-runtime": "^1.0.4" +======= + "@napi-rs/wasm-runtime": "^1.0.5" +>>>>>>> v1.6.0 }, "engines": { "node": ">=14.0.0" } }, "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { +<<<<<<< HEAD "version": "11.7.2", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.7.2.tgz", "integrity": "sha512-9S/VfFcl/Tty7TI/ijXgoh05YUzCwP1ApDZxPU8OPFoVTOqnFPQzR8ysR3i/ajQEcEaiCop0aIqXd0xt7wTxNg==", +======= + "version": "11.8.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.8.4.tgz", + "integrity": "sha512-70vXFs74uA3X5iYOkpclbkWlQEF+MI325uAQ+Or2n8HJip2T0SEmuBlyw/sRL2E8zLC4oocb+1g25fmzlDVkmg==", +>>>>>>> v1.6.0 "cpu": [ "arm64" ], @@ -3795,9 +4378,15 @@ ] }, "node_modules/@oxc-resolver/binding-win32-ia32-msvc": { +<<<<<<< HEAD "version": "11.7.2", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.7.2.tgz", "integrity": "sha512-6ruLagAgDx2CCYWVTJJofee4Lq9Oo9wBmKKZowNPwLgurSTGPO0zQDjPvytQ1PjJuOGisqCVLARBsMwbM20mvA==", +======= + "version": "11.8.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.8.4.tgz", + "integrity": "sha512-SEOUAzTvr+nyMia3nx1dMtD7YUxZwuhQ3QAPnxy21261Lj0yT3JY4EIfwWH54lAWWfMdRSRRMFuGeF/dq7XjEw==", +>>>>>>> v1.6.0 "cpu": [ "ia32" ], @@ -3809,9 +4398,15 @@ ] }, "node_modules/@oxc-resolver/binding-win32-x64-msvc": { +<<<<<<< HEAD "version": "11.7.2", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.7.2.tgz", "integrity": "sha512-gp4xNjGkeeNPxjutTSB1AkYm7JQQof6s7wswzzAKuVZO82L1q4HcOz8QYa5PKPP+r2VHUAJAI+FO/X0pNfWn3w==", +======= + "version": "11.8.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.8.4.tgz", + "integrity": "sha512-1gARIQsOPOU7LJ7jvMyPmZEVMapL/PymeG3J7naOdLZDrIZKX6CTvgawJmETYKt+8icP8M6KbBinrVkKVqFd+A==", +>>>>>>> v1.6.0 "cpu": [ "x64" ], @@ -4566,9 +5161,15 @@ "license": "MIT" }, "node_modules/@rolldown/pluginutils": { +<<<<<<< HEAD "version": "1.0.0-beta.34", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.34.tgz", "integrity": "sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA==", +======= + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", + "integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==", +>>>>>>> v1.6.0 "dev": true, "license": "MIT" }, @@ -4897,16 +5498,28 @@ "license": "Apache-2.0" }, "node_modules/@storybook/addon-docs": { +<<<<<<< HEAD "version": "9.1.6", "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-9.1.6.tgz", "integrity": "sha512-4ZE/T2Ayw77/v2ersAk/VM7vlvqV2zCNFwt0uvOzUR1VZ9VqZCHhsfy/IyBPeKt6Otax3EpfE1LkH4slfceB0g==", +======= + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-9.1.13.tgz", + "integrity": "sha512-V1nCo7bfC3kQ5VNVq0VDcHsIhQf507m+BxMA5SIYiwdJHljH2BXpW2fL3FFn9gv9Wp57AEEzhm+wh4zANaJgkg==", +>>>>>>> v1.6.0 "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", +<<<<<<< HEAD "@storybook/csf-plugin": "9.1.6", "@storybook/icons": "^1.4.0", "@storybook/react-dom-shim": "9.1.6", +======= + "@storybook/csf-plugin": "9.1.13", + "@storybook/icons": "^1.4.0", + "@storybook/react-dom-shim": "9.1.13", +>>>>>>> v1.6.0 "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" @@ -4916,6 +5529,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { +<<<<<<< HEAD "storybook": "^9.1.6" } }, @@ -4927,6 +5541,19 @@ "license": "MIT", "dependencies": { "@storybook/csf-plugin": "9.1.6", +======= + "storybook": "^9.1.13" + } + }, + "node_modules/@storybook/builder-vite": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-9.1.13.tgz", + "integrity": "sha512-pmtIjU02ASJOZKdL8DoxWXJgZnpTDgD5WmMnjKJh9FaWmc2YiCW2Y6VRxPox96OM655jYHQe5+UIbk3Cwtwb4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/csf-plugin": "9.1.13", +>>>>>>> v1.6.0 "ts-dedent": "^2.0.0" }, "funding": { @@ -4934,14 +5561,24 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { +<<<<<<< HEAD "storybook": "^9.1.6", +======= + "storybook": "^9.1.13", +>>>>>>> v1.6.0 "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "node_modules/@storybook/csf-plugin": { +<<<<<<< HEAD "version": "9.1.6", "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-9.1.6.tgz", "integrity": "sha512-cz4Y+OYCtuovFNwoLkIKk0T62clrRTYf26Bbo1gdIGuX/W3JPP/LnN97sP2/0nfF6heZqCdEwb47k7RubkxXZg==", +======= + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-9.1.13.tgz", + "integrity": "sha512-EMpzYuyt9FDcxxfBChWzfId50y8QMpdenviEQ8m+pa6c+ANx3pC5J6t7y0khD8TQu815sTy+nc6cc8PC45dPUA==", +>>>>>>> v1.6.0 "dev": true, "license": "MIT", "dependencies": { @@ -4952,7 +5589,11 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { +<<<<<<< HEAD "storybook": "^9.1.6" +======= + "storybook": "^9.1.13" +>>>>>>> v1.6.0 } }, "node_modules/@storybook/global": { @@ -4977,14 +5618,24 @@ } }, "node_modules/@storybook/react": { +<<<<<<< HEAD "version": "9.1.6", "resolved": "https://registry.npmjs.org/@storybook/react/-/react-9.1.6.tgz", "integrity": "sha512-BGf3MQaXj6LmYnYpSwHUoWH0RP6kaqBoPc2u5opSU2ajw34enIL5w2sFaXzL+k2ap0aHnCYYlyBINBBvtD6NIA==", +======= + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-9.1.13.tgz", + "integrity": "sha512-B0UpYikKf29t8QGcdmumWojSQQ0phSDy/Ne2HYdrpNIxnUvHHUVOlGpq4lFcIDt52Ip5YG5GuAwJg3+eR4LCRg==", +>>>>>>> v1.6.0 "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", +<<<<<<< HEAD "@storybook/react-dom-shim": "9.1.6" +======= + "@storybook/react-dom-shim": "9.1.13" +>>>>>>> v1.6.0 }, "engines": { "node": ">=20.0.0" @@ -4996,7 +5647,11 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", +<<<<<<< HEAD "storybook": "^9.1.6", +======= + "storybook": "^9.1.13", +>>>>>>> v1.6.0 "typescript": ">= 4.9.x" }, "peerDependenciesMeta": { @@ -5006,9 +5661,15 @@ } }, "node_modules/@storybook/react-dom-shim": { +<<<<<<< HEAD "version": "9.1.6", "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-9.1.6.tgz", "integrity": "sha512-Px4duzPMTPqI3kes6eUyYjWpEeJ0AOCCeSDCBDm9rzlf4a+eXlxfhkcVWft3viCDiIkc0vtYagb2Yu7bcSIypg==", +======= + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-9.1.13.tgz", + "integrity": "sha512-/tMr9TmV3+98GEQO0S03k4gtKHGCpv9+k9Dmnv+TJK3TBz7QsaFEzMwe3gCgoTaebLACyVveDiZkWnCYAWB6NA==", +>>>>>>> v1.6.0 "dev": true, "license": "MIT", "funding": { @@ -5018,6 +5679,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", +<<<<<<< HEAD "storybook": "^9.1.6" } }, @@ -5025,13 +5687,27 @@ "version": "9.1.6", "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-9.1.6.tgz", "integrity": "sha512-YNKQZcz5Vtv8OdHUJ65Wx4PbfZMrPPbtL+OYAR0We+EEoTDofi3VogXyOUw99Jppp1HIq5IiDF5qyZPEpC5k0A==", +======= + "storybook": "^9.1.13" + } + }, + "node_modules/@storybook/react-vite": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-9.1.13.tgz", + "integrity": "sha512-mV1bZ1bpkNQygnuDo1xMGAS5ZXuoXFF0WGmr/BzNDGmRhZ1K1HQh42kC0w3PklckFBUwCFxmP58ZwTFzf+/dJA==", +>>>>>>> v1.6.0 "dev": true, "license": "MIT", "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "0.6.1", "@rollup/pluginutils": "^5.0.2", +<<<<<<< HEAD "@storybook/builder-vite": "9.1.6", "@storybook/react": "9.1.6", +======= + "@storybook/builder-vite": "9.1.13", + "@storybook/react": "9.1.13", +>>>>>>> v1.6.0 "find-up": "^7.0.0", "magic-string": "^0.30.0", "react-docgen": "^8.0.0", @@ -5048,7 +5724,11 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", +<<<<<<< HEAD "storybook": "^9.1.6", +======= + "storybook": "^9.1.13", +>>>>>>> v1.6.0 "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, @@ -5066,9 +5746,15 @@ } }, "node_modules/@tanstack/query-core": { +<<<<<<< HEAD "version": "5.89.0", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.89.0.tgz", "integrity": "sha512-joFV1MuPhSLsKfTzwjmPDrp8ENfZ9N23ymFu07nLfn3JCkSHy0CFgsyhHTJOmWaumC/WiNIKM0EJyduCF/Ih/Q==", +======= + "version": "5.90.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.5.tgz", + "integrity": "sha512-wLamYp7FaDq6ZnNehypKI5fNvxHPfTYylE0m/ZpuuzJfJqhR5Pxg9gvGBHZx4n7J+V5Rg5mZxHHTlv25Zt5u+w==", +>>>>>>> v1.6.0 "license": "MIT", "funding": { "type": "github", @@ -5076,9 +5762,15 @@ } }, "node_modules/@tanstack/query-devtools": { +<<<<<<< HEAD "version": "5.87.3", "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.87.3.tgz", "integrity": "sha512-LkzxzSr2HS1ALHTgDmJH5eGAVsSQiuwz//VhFW5OqNk0OQ+Fsqba0Tsf+NzWRtXYvpgUqwQr4b2zdFZwxHcGvg==", +======= + "version": "5.90.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.90.1.tgz", + "integrity": "sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ==", +>>>>>>> v1.6.0 "dev": true, "license": "MIT", "funding": { @@ -5087,12 +5779,21 @@ } }, "node_modules/@tanstack/react-query": { +<<<<<<< HEAD "version": "5.89.0", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.89.0.tgz", "integrity": "sha512-SXbtWSTSRXyBOe80mszPxpEbaN4XPRUp/i0EfQK1uyj3KCk/c8FuPJNIRwzOVe/OU3rzxrYtiNabsAmk1l714A==", "license": "MIT", "dependencies": { "@tanstack/query-core": "5.89.0" +======= + "version": "5.90.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.5.tgz", + "integrity": "sha512-pN+8UWpxZkEJ/Rnnj2v2Sxpx1WFlaa9L6a4UO89p6tTQbeo+m0MS8oYDjbggrR8QcTyjKoYWKS3xJQGr3ExT8Q==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.5" +>>>>>>> v1.6.0 }, "funding": { "type": "github", @@ -5103,6 +5804,7 @@ } }, "node_modules/@tanstack/react-query-devtools": { +<<<<<<< HEAD "version": "5.89.0", "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.89.0.tgz", "integrity": "sha512-Syc4UjZeIJCkXCRGyQcWwlnv89JNb98MMg/DAkFCV3rwOcknj98+nG3Nm6xLXM6ne9sK6RZeDJMPLKZUh6NUGA==", @@ -5110,13 +5812,26 @@ "license": "MIT", "dependencies": { "@tanstack/query-devtools": "5.87.3" +======= + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.90.2.tgz", + "integrity": "sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.90.1" +>>>>>>> v1.6.0 }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { +<<<<<<< HEAD "@tanstack/react-query": "^5.89.0", +======= + "@tanstack/react-query": "^5.90.2", +>>>>>>> v1.6.0 "react": "^18 || ^19" } }, @@ -5399,9 +6114,9 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.8.0.tgz", - "integrity": "sha512-WgXcWzVM6idy5JaftTVC8Vs83NKRmGJz4Hqs4oyOuO2J4r/y79vvKZsb+CaGyCSEbUPI6OsewfPd0G1A0/TUZQ==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, "license": "MIT", "dependencies": { @@ -5560,13 +6275,6 @@ "@types/deep-eql": "*" } }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -5588,13 +6296,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/js-yaml": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", - "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/mdx": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", @@ -5610,6 +6311,7 @@ "license": "MIT" }, "node_modules/@types/node": { +<<<<<<< HEAD "version": "24.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.0.tgz", "integrity": "sha512-y1dMvuvJspJiPSDZUQ+WMBvF7dpnEqN4x9DDC9ie5Fs/HUZJA3wFp7EhHoVaKX/iI0cRoECV8X2jL8zi0xrHCg==", @@ -5623,18 +6325,41 @@ "version": "19.1.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", +======= + "version": "24.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", + "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "devOptional": true, +>>>>>>> v1.6.0 "license": "MIT", "dependencies": { "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { +<<<<<<< HEAD "version": "19.1.9", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", +======= + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", + "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", + "devOptional": true, +>>>>>>> v1.6.0 "license": "MIT", "peerDependencies": { - "@types/react": "^19.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@types/resolve": { @@ -5683,8 +6408,14 @@ } }, "node_modules/@vector-im/compound-design-tokens": { +<<<<<<< HEAD "version": "5.0.1", "resolved": "git+ssh://git@github.com/tchapgouv/compound-design-tokens.git#ec50d7ea4be10b24ac9d0029c45858b774b20314", +======= + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@vector-im/compound-design-tokens/-/compound-design-tokens-6.0.0.tgz", + "integrity": "sha512-Jk0NsLPCvdcuZi6an1cfyf4MDcIuoPlvja5ZWgJcORyGQZV1eLMHPYKShq9gj+EYk/BXZoPvQ1d6/T+/LSCNPA==", +>>>>>>> v1.6.0 "license": "SEE LICENSE IN README.md", "peerDependencies": { "@types/react": "*", @@ -5729,6 +6460,7 @@ } }, "node_modules/@vitejs/plugin-react": { +<<<<<<< HEAD "version": "5.0.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.2.tgz", "integrity": "sha512-tmyFgixPZCx2+e6VO9TNITWcCQl8+Nl/E8YbAyPVv85QCc7/A3JrdfG2A8gIzvVhWuzMOVrFW1aReaNxrI6tbw==", @@ -5739,6 +6471,18 @@ "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.34", +======= + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz", + "integrity": "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.38", +>>>>>>> v1.6.0 "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, @@ -5993,6 +6737,7 @@ "node": ">=0.4.0" } }, +<<<<<<< HEAD "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -6017,17 +6762,19 @@ "node": ">=8" } }, +======= +>>>>>>> v1.6.0 "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.1.0.tgz", + "integrity": "sha512-YdhtCd19sKRKfAAUsrcC1wzm4JuzJoiX4pOJqIoW2qmKj5WzG/dL8uUJ0361zaXtHqK7gEhOwtAtz7t3Yq3X5g==", "dev": true, "license": "MIT", "dependencies": { - "type-fest": "^0.21.3" + "environment": "^1.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6208,16 +6955,6 @@ "dev": true, "license": "MIT" }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/auto-bind": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-4.0.0.tgz", @@ -6415,18 +7152,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -6669,31 +7394,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -6954,67 +7654,93 @@ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "license": "MIT" }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "dev": true, "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "node_modules/cli-truncate": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.0.tgz", + "integrity": "sha512-7JDGG+4Zp0CsknDCedl0DYdaeOhc46QNpXi3NLQblkZpXXgA6LncLDUUyvrjSvZeF3VRQa+KiMGomazQrC1V8g==", "dev": true, "license": "MIT", "dependencies": { - "restore-cursor": "^3.1.0" + "slice-ansi": "^7.1.0", + "string-width": "^8.0.0" }, "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", "dev": true, "license": "MIT", "dependencies": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/cli-width": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, "license": "ISC", "engines": { - "node": ">= 10" + "node": ">= 12" } }, "node_modules/cliui": { @@ -7154,13 +7880,13 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" } }, "node_modules/cookie-es": { @@ -7325,11 +8051,17 @@ } }, "node_modules/debounce": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", - "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.2.0.tgz", + "integrity": "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/debug": { "version": "4.4.1", @@ -7369,29 +8101,6 @@ "node": ">=6" } }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/defaults/node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, "node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -7566,6 +8275,7 @@ "tslib": "^2.0.3" } }, +<<<<<<< HEAD "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -7579,6 +8289,8 @@ "url": "https://dotenvx.com" } }, +======= +>>>>>>> v1.6.0 "node_modules/dset": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", @@ -7644,6 +8356,29 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eol": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/eol/-/eol-0.9.1.tgz", @@ -7733,16 +8468,6 @@ "node": ">=6" } }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -7774,6 +8499,13 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", @@ -7912,22 +8644,6 @@ "node": "^12.20 || >= 14.13" } }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -8166,6 +8882,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -8345,20 +9074,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/graphql-request": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-6.1.0.tgz", - "integrity": "sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@graphql-typed-document-node/core": "^3.2.0", - "cross-fetch": "^3.1.5" - }, - "peerDependencies": { - "graphql": "14 - 16" - } - }, "node_modules/graphql-tag": { "version": "2.12.6", "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", @@ -8417,9 +9132,9 @@ } }, "node_modules/happy-dom": { - "version": "18.0.1", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-18.0.1.tgz", - "integrity": "sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA==", + "version": "20.0.4", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.4.tgz", + "integrity": "sha512-WxFtvnij6G64/MtMimnZhF0nKx3LUQKc20zjATD6tKiqOykUwQkd+2FW/DZBAFNjk4oWh0xdv/HBleGJmSY/Iw==", "dev": true, "license": "MIT", "dependencies": { @@ -8583,38 +9298,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/i18next": { +<<<<<<< HEAD "version": "25.5.2", "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.5.2.tgz", "integrity": "sha512-lW8Zeh37i/o0zVr+NoCHfNnfvVw+M6FQbRp36ZZ/NyHDJ3NJVpp2HhAUyU9WafL5AssymNoOjMRB48mmx2P6Hw==", +======= + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.6.0.tgz", + "integrity": "sha512-tTn8fLrwBYtnclpL5aPXK/tAYBLWVvoHM1zdfXoRNLcI+RvtMsoZRV98ePlaW3khHYKuNh/Q65W/+NVFUeIwVw==", +>>>>>>> v1.6.0 "funding": [ { "type": "individual", @@ -8642,39 +9335,6 @@ } } }, - "node_modules/i18next-browser-languagedetector": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", - "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.23.2" - } - }, - "node_modules/i18next-http-backend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", - "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "cross-fetch": "4.0.0" - } - }, - "node_modules/i18next-http-backend/node_modules/cross-fetch": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", - "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "node-fetch": "^2.6.12" - } - }, "node_modules/i18next-parser": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/i18next-parser/-/i18next-parser-9.3.0.tgz", @@ -8864,33 +9524,6 @@ "dev": true, "license": "ISC" }, - "node_modules/inquirer": { - "version": "8.2.7", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", - "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/external-editor": "^1.0.0", - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.1", - "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "figures": "^3.0.0", - "lodash": "^4.17.21", - "mute-stream": "0.0.8", - "ora": "^5.4.1", - "run-async": "^2.4.0", - "rxjs": "^7.5.5", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6", - "wrap-ansi": "^6.0.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -9000,16 +9633,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-lower-case/-/is-lower-case-2.0.2.tgz", @@ -9255,16 +9878,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/jose": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", - "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9346,9 +9959,15 @@ } }, "node_modules/knip": { +<<<<<<< HEAD "version": "5.63.1", "resolved": "https://registry.npmjs.org/knip/-/knip-5.63.1.tgz", "integrity": "sha512-wSznedUAzcU4o9e0O2WPqDnP7Jttu8cesq/R23eregRY8QYQ9NLJ3aGt9fadJfRzPBoU4tRyutwVQu6chhGDlA==", +======= + "version": "5.64.2", + "resolved": "https://registry.npmjs.org/knip/-/knip-5.64.2.tgz", + "integrity": "sha512-gyIN+ZqZjyxdsocvkZx2HMy7D9+5WAgFrTM69sGg1QZ8wZuabtanhAP8ZnroctU26sQ5bO2RSPvjnOn0pRNuKw==", +>>>>>>> v1.6.0 "dev": true, "funding": [ { @@ -9365,16 +9984,27 @@ "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", +<<<<<<< HEAD "jiti": "^2.5.1", "js-yaml": "^4.1.0", "minimist": "^1.2.8", "oxc-resolver": "^11.6.2", +======= + "jiti": "^2.6.0", + "js-yaml": "^4.1.0", + "minimist": "^1.2.8", + "oxc-resolver": "^11.8.3", +>>>>>>> v1.6.0 "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.4.1", "strip-json-comments": "5.0.2", +<<<<<<< HEAD "zod": "^3.25.0", "zod-validation-error": "^3.0.3" +======= + "zod": "^4.1.11" +>>>>>>> v1.6.0 }, "bin": { "knip": "bin/knip.js", @@ -9385,19 +10015,35 @@ }, "peerDependencies": { "@types/node": ">=18", - "typescript": ">=5.0.4" + "typescript": ">=5.0.4 <7" } }, "node_modules/knip/node_modules/jiti": { +<<<<<<< HEAD "version": "2.5.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", +======= + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.0.tgz", + "integrity": "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==", +>>>>>>> v1.6.0 "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/knip/node_modules/zod": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", + "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/lead": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/lead/-/lead-4.0.0.tgz", @@ -9415,60 +10061,117 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=14" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.4.tgz", + "integrity": "sha512-1wd/kpAdKRLwv7/3OKC8zZ5U8e/fajCfWMxacUvB79S5nLrYGPtUI/8chMQhn3LQjsRVErTb9i1ECAwW0ZIHnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/antonk52" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", "dev": true, "license": "MIT" }, - "node_modules/listr2": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-4.0.5.tgz", - "integrity": "sha512-juGHV1doQdpNT3GSTs9IUN43QJb7KHdF9uqg7Vufs/tG9VTzpFphqF4pm/ICdAABGQxsyNn9CiYA3StkI6jpwA==", + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "cli-truncate": "^2.1.0", - "colorette": "^2.0.16", - "log-update": "^4.0.0", - "p-map": "^4.0.0", - "rfdc": "^1.3.0", - "rxjs": "^7.5.5", - "through": "^2.3.8", - "wrap-ansi": "^7.0.0" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=12" + "node": ">=18" }, - "peerDependencies": { - "enquirer": ">= 2.3.0 < 3" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" }, - "peerDependenciesMeta": { - "enquirer": { - "optional": true - } + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/listr2/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -9522,40 +10225,108 @@ } }, "node_modules/log-update": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", - "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-escapes": "^4.3.0", - "cli-cursor": "^3.1.0", - "slice-ansi": "^4.0.0", - "wrap-ansi": "^6.2.0" + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/loose-envify": { @@ -9786,14 +10557,17 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/min-indent": { @@ -9860,21 +10634,24 @@ "license": "MIT" }, "node_modules/msw": { +<<<<<<< HEAD "version": "2.11.2", "resolved": "https://registry.npmjs.org/msw/-/msw-2.11.2.tgz", "integrity": "sha512-MI54hLCsrMwiflkcqlgYYNJJddY5/+S0SnONvhv1owOplvqohKSQyGejpNdUGyCwgs4IH7PqaNbPw/sKOEze9Q==", +======= + "version": "2.11.6", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.11.6.tgz", + "integrity": "sha512-MCYMykvmiYScyUm7I6y0VCxpNq1rgd5v7kG8ks5dKtvmxRUUPjribX6mUoUNBbM5/3PhUyoelEWiKXGOz84c+w==", +>>>>>>> v1.6.0 "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "@bundled-es-modules/cookie": "^2.0.1", - "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^5.0.0", - "@mswjs/interceptors": "^0.39.1", + "@mswjs/interceptors": "^0.40.0", "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/until": "^2.1.0", - "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", + "cookie": "^1.0.2", "graphql": "^16.8.1", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", @@ -9882,9 +10659,14 @@ "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.7.0", +<<<<<<< HEAD +======= + "statuses": "^2.0.2", +>>>>>>> v1.6.0 "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^4.26.1", + "until-async": "^3.0.2", "yargs": "^17.7.2" }, "bin": { @@ -9932,11 +10714,14 @@ } }, "node_modules/mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", "dev": true, - "license": "ISC" + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } }, "node_modules/mz": { "version": "2.7.0", @@ -10136,16 +10921,16 @@ } }, "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", "dev": true, "license": "MIT", "dependencies": { - "mimic-fn": "^2.1.0" + "mimic-function": "^5.0.0" }, "engines": { - "node": ">=6" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -10169,30 +10954,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/outvariant": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", @@ -10201,9 +10962,15 @@ "license": "MIT" }, "node_modules/oxc-resolver": { +<<<<<<< HEAD "version": "11.7.2", "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.7.2.tgz", "integrity": "sha512-abgiTgtJ7FLVPdg5x+rcfoSqz5kpgS/j1Rk/BFNVlLbpAI56VXCj/MM7NyfQb+aVlQDBum0omdz4uFrOYEjNIw==", +======= + "version": "11.8.4", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.8.4.tgz", + "integrity": "sha512-qpimS3tHHEf+kgESMAme+q+rj7aCzMya00u9YdKOKyX2o7q4lozjPo6d7ZTTi979KHEcVOPWdNTueAKdeNq72w==", +>>>>>>> v1.6.0 "dev": true, "hasInstallScript": true, "license": "MIT", @@ -10214,6 +10981,7 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { +<<<<<<< HEAD "@oxc-resolver/binding-android-arm-eabi": "11.7.2", "@oxc-resolver/binding-android-arm64": "11.7.2", "@oxc-resolver/binding-darwin-arm64": "11.7.2", @@ -10233,6 +11001,27 @@ "@oxc-resolver/binding-win32-arm64-msvc": "11.7.2", "@oxc-resolver/binding-win32-ia32-msvc": "11.7.2", "@oxc-resolver/binding-win32-x64-msvc": "11.7.2" +======= + "@oxc-resolver/binding-android-arm-eabi": "11.8.4", + "@oxc-resolver/binding-android-arm64": "11.8.4", + "@oxc-resolver/binding-darwin-arm64": "11.8.4", + "@oxc-resolver/binding-darwin-x64": "11.8.4", + "@oxc-resolver/binding-freebsd-x64": "11.8.4", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.8.4", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.8.4", + "@oxc-resolver/binding-linux-arm64-gnu": "11.8.4", + "@oxc-resolver/binding-linux-arm64-musl": "11.8.4", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.8.4", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.8.4", + "@oxc-resolver/binding-linux-riscv64-musl": "11.8.4", + "@oxc-resolver/binding-linux-s390x-gnu": "11.8.4", + "@oxc-resolver/binding-linux-x64-gnu": "11.8.4", + "@oxc-resolver/binding-linux-x64-musl": "11.8.4", + "@oxc-resolver/binding-wasm32-wasi": "11.8.4", + "@oxc-resolver/binding-win32-arm64-msvc": "11.8.4", + "@oxc-resolver/binding-win32-ia32-msvc": "11.8.4", + "@oxc-resolver/binding-win32-x64-msvc": "11.8.4" +>>>>>>> v1.6.0 } }, "node_modules/p-limit": { @@ -10296,22 +11085,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -10986,9 +11759,9 @@ } }, "node_modules/react": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", - "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11027,28 +11800,34 @@ } }, "node_modules/react-dom": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", - "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", "dependencies": { - "scheduler": "^0.26.0" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.1.1" + "react": "^19.2.0" } }, "node_modules/react-i18next": { +<<<<<<< HEAD "version": "15.7.3", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.3.tgz", "integrity": "sha512-AANws4tOE+QSq/IeMF/ncoHlMNZaVLxpa5uUGW1wjike68elVYr0018L9xYoqBr1OFO7G7boDPrbn0HpMCJxTw==", +======= + "version": "16.1.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.1.4.tgz", + "integrity": "sha512-0UUKZDHjKnLk6dfbYXEZ9CVqLMpNiul+dHbPVQo2z2t1GkdirkeHXb/TtdsNuv+nyNOTDl1Jp6F6uwf9M3DMcg==", +>>>>>>> v1.6.0 "license": "MIT", "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { - "i18next": ">= 25.4.1", + "i18next": ">= 25.5.2", "react": ">= 16.8.0", "typescript": "^5" }, @@ -11374,25 +12153,28 @@ } }, "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dev": true, "license": "MIT", "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "node_modules/rettime": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", + "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", "dev": true, - "license": "ISC" + "license": "MIT" }, "node_modules/rettime": { "version": "0.7.0", @@ -11573,16 +12355,6 @@ "node": "6.* || >= 7.*" } }, - "node_modules/run-async": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -11607,16 +12379,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -11646,16 +12408,9 @@ "license": "MIT" }, "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", - "license": "MIT" - }, - "node_modules/scuid": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/scuid/-/scuid-1.1.0.tgz", - "integrity": "sha512-MuCAyrGZcTLfQoH2XoBlQ8C6bzwN88XT/0slOGz0pn8+gIP85BOAfYa44ZXQUTOwRwPU0QvgU+V+OSajl/59Xg==", - "dev": true, + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, "node_modules/semver": { @@ -11789,18 +12544,49 @@ } }, "node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/smol-toml": { @@ -11917,9 +12703,15 @@ "license": "MIT" }, "node_modules/storybook": { +<<<<<<< HEAD "version": "9.1.6", "resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.6.tgz", "integrity": "sha512-iIcMaDKkjR5nN+JYBy9hhoxZhjX4TXhyJgUBed+toJOlfrl+QvxpBjImAi7qKyLR3hng3uoigEP0P8+vYtXpOg==", +======= + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.13.tgz", + "integrity": "sha512-G3KZ36EVzXyHds72B/qtWiJnhUpM0xOUeYlDcO9DSHL1bDTv15cW4+upBl+mcBZrDvU838cn7Bv4GpF+O5MCfw==", +>>>>>>> v1.6.0 "dev": true, "license": "MIT", "dependencies": { @@ -11952,35 +12744,17 @@ } } }, - "node_modules/storybook-i18n": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/storybook-i18n/-/storybook-i18n-4.0.5.tgz", - "integrity": "sha512-uy6k7N5VU8PRSoMo6tVYo1WNSDRd8Z3goSku7J1Cz8A8WseBN5xAnGZ/IbO5DLUOVBetLZdaKHBVoLKbYidHjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/icons": "^1.4.0" - }, - "peerDependencies": { - "storybook": "^9.0.0" - } - }, - "node_modules/storybook-react-i18next": { - "version": "4.0.11", - "resolved": "https://registry.npmjs.org/storybook-react-i18next/-/storybook-react-i18next-4.0.11.tgz", - "integrity": "sha512-p6gcz8//n7mtBaP75yZx910/t9Z4aIwOP+xzCvxwTzWL19NT1YGTR4GyR0ybzbEebqlPJtJVHnpGKQQD4wRyYg==", + "node_modules/storybook/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, - "license": "MIT", - "dependencies": { - "storybook-i18n": "^4.0.5" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, - "peerDependencies": { - "i18next": "^22.0.0 || ^23.0.0 || ^24.0.0 || ^25.0.0", - "i18next-browser-languagedetector": "^7.0.0 || ^8.0.0", - "i18next-http-backend": "^2.0.0 || ^3.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-i18next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0", - "storybook": "^9.0.0" + "engines": { + "node": ">=10" } }, "node_modules/storybook/node_modules/semver": { @@ -12218,9 +12992,15 @@ } }, "node_modules/swagger-ui-dist": { +<<<<<<< HEAD "version": "5.29.0", "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.29.0.tgz", "integrity": "sha512-gqs7Md3AxP4mbpXAq31o5QW+wGUZsUzVatg70yXpUR245dfIis5jAzufBd+UQM/w2xSfrhvA1eqsrgnl2PbezQ==", +======= + "version": "5.29.5", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.29.5.tgz", + "integrity": "sha512-2zFnjONgLXlz8gLToRKvXHKJdqXF6UGgCmv65i8T6i/UrjDNyV1fIQ7FauZA40SaivlGKEvW2tw9XDyDhfcXqQ==", +>>>>>>> v1.6.0 "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -12294,9 +13074,9 @@ "license": "MIT" }, "node_modules/tailwindcss": { - "version": "3.4.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", - "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "version": "3.4.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", + "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12308,7 +13088,7 @@ "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.21.6", + "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", @@ -12317,7 +13097,7 @@ "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", @@ -12434,13 +13214,6 @@ "node": ">=0.8" } }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true, - "license": "MIT" - }, "node_modules/through2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", @@ -12709,6 +13482,7 @@ "fsevents": "~2.3.3" } }, +<<<<<<< HEAD "node_modules/type-fest": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", @@ -12726,6 +13500,13 @@ "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", +======= + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, +>>>>>>> v1.6.0 "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -12797,9 +13578,15 @@ } }, "node_modules/undici-types": { +<<<<<<< HEAD "version": "7.12.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", +======= + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", +>>>>>>> v1.6.0 "dev": true, "license": "MIT" }, @@ -12866,6 +13653,16 @@ "node": ">=14.0.0" } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -13132,9 +13929,15 @@ } }, "node_modules/vite": { +<<<<<<< HEAD "version": "7.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", +======= + "version": "7.1.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", + "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", +>>>>>>> v1.6.0 "dev": true, "license": "MIT", "dependencies": { @@ -13260,13 +14063,19 @@ } }, "node_modules/vite-plugin-graphql-codegen": { +<<<<<<< HEAD "version": "3.6.3", "resolved": "https://registry.npmjs.org/vite-plugin-graphql-codegen/-/vite-plugin-graphql-codegen-3.6.3.tgz", "integrity": "sha512-A6C5fEGg26jG4bUhxTRH3KegFXNJ4Vxy4x/F/UKTfl73wOeb64qA3/rJFybyambvjNFndyameNazyeUglfH4Qg==", +======= + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/vite-plugin-graphql-codegen/-/vite-plugin-graphql-codegen-3.7.0.tgz", + "integrity": "sha512-6TXkpUPZunV+RHP+A5R6ohar6WWjfWxTN8OpBsrZmGlJlVEpwc+2FaquAtUwO1B6kzxEomqJ7q5Idnns57hTxg==", +>>>>>>> v1.6.0 "dev": true, "license": "MIT", "peerDependencies": { - "@graphql-codegen/cli": ">=1.0.0 <6.0.0", + "@graphql-codegen/cli": ">=1.0.0 <7.0.0", "graphql": ">=14.0.0 <17.0.0", "vite": ">=2.7.0 <8.0.0" } @@ -13410,16 +14219,6 @@ "node": "20 || >=22" } }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -13621,13 +14420,6 @@ "node": ">= 14.6" } }, - "node_modules/yaml-ast-parser": { - "version": "0.0.43", - "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", - "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -13692,6 +14484,7 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } +<<<<<<< HEAD }, "node_modules/zod-validation-error": { "version": "3.5.3", @@ -13705,6 +14498,8 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } +======= +>>>>>>> v1.6.0 } } } diff --git a/frontend/package.json b/frontend/package.json index cff6ee1ef..c8b883c1c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "knip": "knip" }, "dependencies": { +<<<<<<< HEAD "@fontsource/inconsolata": "^5.2.7", "@fontsource/inter": "^5.2.7", "@gouvfr-lasuite/integration": "^1.0.3", @@ -28,20 +29,38 @@ "@tanstack/react-query": "^5.89.0", "@tanstack/react-router": "^1.131.44", "@vector-im/compound-design-tokens": "git+https://github.com/tchapgouv/compound-design-tokens.git", +======= + "@fontsource/inconsolata": "^5.2.8", + "@fontsource/inter": "^5.2.8", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@tanstack/react-query": "^5.90.5", + "@tanstack/react-router": "^1.131.44", + "@vector-im/compound-design-tokens": "6.0.0", +>>>>>>> v1.6.0 "@vector-im/compound-web": "^8.2.4", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "classnames": "^2.5.1", "date-fns": "^4.1.0", +<<<<<<< HEAD "i18next": "^25.5.2", "react": "^19.1.1", "react-dom": "^19.1.1", "react-i18next": "^15.7.3", "swagger-ui-dist": "^5.29.0", +======= + "i18next": "^25.6.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-i18next": "^16.1.4", + "swagger-ui-dist": "^5.29.5", +>>>>>>> v1.6.0 "valibot": "^1.1.0", "vaul": "^1.1.2" }, "devDependencies": { +<<<<<<< HEAD "@biomejs/biome": "^2.2.4", "@browser-logos/chrome": "^2.0.0", "@browser-logos/firefox": "^3.0.10", @@ -62,26 +81,61 @@ "@types/react-dom": "19.1.9", "@types/swagger-ui-dist": "^3.30.6", "@vitejs/plugin-react": "^5.0.2", +======= + "@biomejs/biome": "^2.2.5", + "@browser-logos/chrome": "^2.0.0", + "@browser-logos/firefox": "^3.0.10", + "@browser-logos/safari": "^2.1.0", + "@graphql-codegen/cli": "^6.0.1", + "@graphql-codegen/client-preset": "^5.1.0", + "@graphql-codegen/typescript-msw": "^3.0.1", + "@storybook/addon-docs": "^9.1.13", + "@storybook/react-vite": "^9.1.13", + "@tanstack/react-query-devtools": "^5.90.2", + "@tanstack/react-router-devtools": "^1.131.44", + "@tanstack/router-plugin": "^1.131.44", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.9.1", + "@types/react": "19.2.2", + "@types/react-dom": "19.2.2", + "@types/swagger-ui-dist": "^3.30.6", + "@vitejs/plugin-react": "^5.0.4", +>>>>>>> v1.6.0 "@vitest/coverage-v8": "^3.2.4", "autoprefixer": "^10.4.21", "browserslist-to-esbuild": "^2.1.1", "graphql": "^16.11.0", - "happy-dom": "^18.0.1", + "happy-dom": "^20.0.4", "i18next-parser": "^9.3.0", +<<<<<<< HEAD "knip": "^5.63.1", "msw": "^2.11.2", +======= + "knip": "^5.64.2", + "msw": "^2.11.6", +>>>>>>> v1.6.0 "msw-storybook-addon": "^2.0.5", "postcss": "^8.5.6", "postcss-import": "^16.1.1", "postcss-nesting": "^13.0.2", "rimraf": "^6.0.1", "storybook": "^9.1.5", +<<<<<<< HEAD "storybook-react-i18next": "4.0.11", "tailwindcss": "^3.4.17", "typescript": "^5.9.2", "vite": "7.1.5", "vite-plugin-compression": "^0.5.1", "vite-plugin-graphql-codegen": "^3.6.3", +======= + "tailwindcss": "^3.4.18", + "typescript": "^5.9.3", + "vite": "7.1.11", + "vite-plugin-compression": "^0.5.1", + "vite-plugin-graphql-codegen": "^3.7.0", +>>>>>>> v1.6.0 "vite-plugin-manifest-sri": "^0.2.0", "vitest": "^3.2.4" }, diff --git a/frontend/src/components/SessionDetail/SessionInfo.tsx b/frontend/src/components/SessionDetail/SessionInfo.tsx index e170e9487..c5856587a 100644 --- a/frontend/src/components/SessionDetail/SessionInfo.tsx +++ b/frontend/src/components/SessionDetail/SessionInfo.tsx @@ -3,10 +3,11 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +import IconAdmin from "@vector-im/compound-design-tokens/assets/web/icons/admin"; import IconChat from "@vector-im/compound-design-tokens/assets/web/icons/chat"; import IconComputer from "@vector-im/compound-design-tokens/assets/web/icons/computer"; -import IconErrorSolid from "@vector-im/compound-design-tokens/assets/web/icons/error-solid"; import IconInfo from "@vector-im/compound-design-tokens/assets/web/icons/info"; +import IconRoom from "@vector-im/compound-design-tokens/assets/web/icons/room"; import IconSend from "@vector-im/compound-design-tokens/assets/web/icons/send"; import IconUserProfile from "@vector-im/compound-design-tokens/assets/web/icons/user-profile"; import { @@ -68,7 +69,7 @@ export const ScopeSendMessages: React.FC = () => { const ScopeSynapseAdmin: React.FC = () => { const { t } = useTranslation(); return ( - + {t("mas.scope.synapse_admin")} ); @@ -77,7 +78,7 @@ const ScopeSynapseAdmin: React.FC = () => { const ScopeMasAdmin: React.FC = () => { const { t } = useTranslation(); return ( - + {t("mas.scope.mas_admin")} ); diff --git a/frontend/src/components/SessionDetail/__snapshots__/CompatSessionDetail.test.tsx.snap b/frontend/src/components/SessionDetail/__snapshots__/CompatSessionDetail.test.tsx.snap index d07848769..7849c7661 100644 --- a/frontend/src/components/SessionDetail/__snapshots__/CompatSessionDetail.test.tsx.snap +++ b/frontend/src/components/SessionDetail/__snapshots__/CompatSessionDetail.test.tsx.snap @@ -29,10 +29,10 @@ exports[` > renders a compatability session details 1`] = ` > element.io: Unknown device