From 3531f2ec5d5be3bb5cf21e827552afd1d2ed9045 Mon Sep 17 00:00:00 2001 From: David Cook Date: Thu, 6 Jul 2023 12:42:09 -0500 Subject: [PATCH 1/3] interface for api tokens and another cleanup pass through the app, which was becoming difficult to work in --- Cargo.lock | 388 ++++++++---------- app/src/ApiClient.ts | 63 ++- app/src/{ => accounts}/AccountForm.tsx | 0 app/src/{ => accounts}/AccountList.tsx | 8 +- app/src/{ => accounts}/AccountSummary.tsx | 24 +- app/src/accounts/index.tsx | 81 ++++ app/src/admin/index.tsx | 33 ++ .../{ => aggregators}/AggregatorDetail.tsx | 13 +- app/src/{ => aggregators}/AggregatorForm.tsx | 9 +- app/src/{ => aggregators}/AggregatorList.tsx | 9 +- app/src/aggregators/index.tsx | 52 +++ app/src/api-tokens/ApiTokenList.tsx | 265 ++++++++++++ app/src/api-tokens/index.tsx | 58 +++ app/src/{ => layout}/ErrorPage.tsx | 23 +- app/src/{ => layout}/Header.tsx | 41 +- app/src/{ => layout}/Layout.tsx | 0 app/src/layout/index.tsx | 31 ++ app/src/{ => memberships}/Memberships.tsx | 7 +- app/src/memberships/index.tsx | 35 ++ app/src/router.tsx | 329 ++------------- app/src/{ => tasks}/TaskDetail.tsx | 32 +- app/src/{ => tasks}/TaskForm.tsx | 20 +- app/src/{ => tasks}/TaskList.tsx | 7 +- app/src/tasks/index.tsx | 65 +++ app/src/util.tsx | 9 +- migration/Cargo.toml | 2 + migration/src/lib.rs | 2 + ...332_add_additional_fields_to_api_tokens.rs | 71 ++++ src/entity.rs | 4 +- src/entity/api_token.rs | 41 +- src/routes.rs | 1 + src/routes/api_tokens.rs | 21 +- tests/api_tokens.rs | 86 +++- 33 files changed, 1207 insertions(+), 623 deletions(-) rename app/src/{ => accounts}/AccountForm.tsx (100%) rename app/src/{ => accounts}/AccountList.tsx (89%) rename app/src/{ => accounts}/AccountSummary.tsx (87%) create mode 100644 app/src/accounts/index.tsx create mode 100644 app/src/admin/index.tsx rename app/src/{ => aggregators}/AggregatorDetail.tsx (87%) rename app/src/{ => aggregators}/AggregatorForm.tsx (95%) rename app/src/{ => aggregators}/AggregatorList.tsx (88%) create mode 100644 app/src/aggregators/index.tsx create mode 100644 app/src/api-tokens/ApiTokenList.tsx create mode 100644 app/src/api-tokens/index.tsx rename app/src/{ => layout}/ErrorPage.tsx (72%) rename app/src/{ => layout}/Header.tsx (66%) rename app/src/{ => layout}/Layout.tsx (100%) create mode 100644 app/src/layout/index.tsx rename app/src/{ => memberships}/Memberships.tsx (95%) create mode 100644 app/src/memberships/index.tsx rename app/src/{ => tasks}/TaskDetail.tsx (90%) rename app/src/{ => tasks}/TaskForm.tsx (98%) rename app/src/{ => tasks}/TaskList.tsx (89%) create mode 100644 app/src/tasks/index.tsx create mode 100644 migration/src/m20230703_201332_add_additional_fields_to_api_tokens.rs diff --git a/Cargo.lock b/Cargo.lock index 98044c15..03d9d301 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -167,7 +167,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" dependencies = [ - "windows-sys 0.48.0", + "windows-sys", ] [[package]] @@ -177,7 +177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188" dependencies = [ "anstyle", - "windows-sys 0.48.0", + "windows-sys", ] [[package]] @@ -306,7 +306,7 @@ dependencies = [ "log", "parking", "polling", - "rustix", + "rustix 0.37.23", "slab", "socket2", "waker-fn", @@ -328,7 +328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29479d362e242e320fa8f5c831940a5b83c1679af014068196cd20d4bf497b6b" dependencies = [ "futures-io", - "rustls 0.21.2", + "rustls 0.21.3", ] [[package]] @@ -398,7 +398,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.23", ] [[package]] @@ -409,13 +409,13 @@ checksum = "ecc7ab41815b3c653ccd2978ec3255c81349336702dfdf62ee6f7069b12a3aae" [[package]] name = "async-trait" -version = "0.1.68" +version = "0.1.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842" +checksum = "a564d521dd56509c4c47480d00b80ee55f7e385ae48db5744c67ad50c92d2ebf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.23", ] [[package]] @@ -522,6 +522,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" + [[package]] name = "bitvec" version = "1.0.1" @@ -741,7 +747,7 @@ version = "3.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123" dependencies = [ - "bitflags", + "bitflags 1.3.2", "clap_derive 3.2.25", "clap_lex 0.2.4", "indexmap 1.9.3", @@ -751,9 +757,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.3.10" +version = "4.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384e169cc618c613d5e3ca6404dda77a8685a63e08660dcc64abaf7da7cb0c7a" +checksum = "1640e5cc7fb47dbb8338fd471b105e7ed6c3cb2aeb00c2e067127ffd3764a05d" dependencies = [ "clap_builder", "clap_derive 4.3.2", @@ -762,9 +768,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.3.10" +version = "4.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef137bbe35aab78bdb468ccfba75a5f4d8321ae011d34063770780545176af2d" +checksum = "98c59138d527eeaf9b53f35a77fcc1fad9d883116070c63d5de1c7dc7b00c72b" dependencies = [ "anstream", "anstyle", @@ -794,7 +800,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.23", ] [[package]] @@ -820,13 +826,13 @@ checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" [[package]] name = "colored" -version = "2.0.0" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3616f750b84d8f0de8a58bda93e08e2a81ad3f523089b05f1dffecab48c6cbd" +checksum = "2674ec482fbc38012cf31e6c42ba0177b431a0cb6f15fe40efa5aab1bda516f6" dependencies = [ - "atty", + "is-terminal", "lazy_static", - "winapi", + "windows-sys", ] [[package]] @@ -880,9 +886,9 @@ checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" [[package]] name = "cpufeatures" -version = "0.2.8" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03e69e28e9f7f77debdedbaafa2866e1de9ba56df55a8bd7cfc724c25a09987c" +checksum = "a17b76ff3a4162b0b27f354a0c87015ddad39d35f9c0c36607a3bdd175dde1f1" dependencies = [ "libc", ] @@ -956,16 +962,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "ctor" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d2301688392eb071b0bf1a37be05c469d3cc4dbbd95df672fe28ab021e6a096" -dependencies = [ - "quote", - "syn 1.0.109", -] - [[package]] name = "ctr" version = "0.9.2" @@ -1158,7 +1154,7 @@ checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" dependencies = [ "errno-dragonfly", "libc", - "windows-sys 0.48.0", + "windows-sys", ] [[package]] @@ -1326,7 +1322,7 @@ checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.23", ] [[package]] @@ -1498,18 +1494,9 @@ dependencies = [ [[package]] name = "hermit-abi" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" -dependencies = [ - "libc", -] - -[[package]] -name = "hermit-abi" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286" +checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" [[package]] name = "hex" @@ -1657,21 +1644,20 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" dependencies = [ - "hermit-abi 0.3.1", + "hermit-abi 0.3.2", "libc", - "windows-sys 0.48.0", + "windows-sys", ] [[package]] name = "is-terminal" -version = "0.4.7" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adcf93614601c8129ddf72e2d5633df827ba6551541c6d8c59520a371475be1f" +checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" dependencies = [ - "hermit-abi 0.3.1", - "io-lifetimes", - "rustix", - "windows-sys 0.48.0", + "hermit-abi 0.3.2", + "rustix 0.38.3", + "windows-sys", ] [[package]] @@ -1685,15 +1671,15 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.6" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" +checksum = "62b02a5381cc465bd3041d84623d0fa3b66738b52b8e2fc3bab8ad63ab032f4a" [[package]] name = "janus_messages" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81bdf827f8d3a5c0bd7c12184ffa099553548a65ba0653c34056d43ea36f25f8" +checksum = "0d80d70dd34636ebb0f3b5b1b229b4c55bdf8ee0fb33e9f847a55ac7a6592c6d" dependencies = [ "anyhow", "base64 0.21.2", @@ -1762,6 +1748,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" +[[package]] +name = "linux-raw-sys" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0" + [[package]] name = "lock_api" version = "0.4.10" @@ -1787,7 +1779,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" dependencies = [ - "regex-automata", + "regex-automata 0.1.10", ] [[package]] @@ -1816,10 +1808,11 @@ name = "migration" version = "0.1.0" dependencies = [ "async-std", - "clap 4.3.10", + "clap 4.3.11", "sea-orm", "sea-orm-migration", "thiserror", + "time", "tracing", "tracing-subscriber", ] @@ -1863,7 +1856,7 @@ checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" dependencies = [ "libc", "wasi", - "windows-sys 0.48.0", + "windows-sys", ] [[package]] @@ -1918,11 +1911,11 @@ dependencies = [ [[package]] name = "num_cpus" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" dependencies = [ - "hermit-abi 0.2.6", + "hermit-abi 0.3.2", "libc", ] @@ -1944,7 +1937,7 @@ dependencies = [ "proc-macro-crate 1.3.1", "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.23", ] [[package]] @@ -2088,15 +2081,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "output_vt100" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "628223faebab4e3e40667ee0b2336d34a5b960ff60ea743ddfdbcf7770bcfb66" -dependencies = [ - "winapi", -] - [[package]] name = "overload" version = "0.1.1" @@ -2159,9 +2143,9 @@ dependencies = [ [[package]] name = "paste" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f746c4065a8fa3fe23974dd82f15431cc8d40779821001404d10d2e79ca7d79" +checksum = "b4b27ab7be369122c218afc2079489cdcb4b517c0a3fc386ff11e1fedfcc2b35" [[package]] name = "percent-encoding" @@ -2171,29 +2155,29 @@ checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" [[package]] name = "pin-project" -version = "1.1.0" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c95a7476719eab1e366eaf73d0260af3021184f18177925b07f54b30089ceead" +checksum = "030ad2bc4db10a8944cb0d837f158bdfec4d4a4873ab701a95046770d11f8842" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.0" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39407670928234ebc5e6e580247dd567ad73a3578460c5990f9503df207e8f07" +checksum = "ec2e072ecce94ec471b13398d5402c188e76ac03cf74dd1a975161b23a3f6d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.23", ] [[package]] name = "pin-project-lite" -version = "0.2.9" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" +checksum = "4c40d25201921e5ff0c862a505c6557ea88568a4e3ace775ab55e93f2f4f9d57" [[package]] name = "pin-utils" @@ -2214,13 +2198,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" dependencies = [ "autocfg", - "bitflags", + "bitflags 1.3.2", "cfg-if 1.0.0", "concurrent-queue", "libc", "log", "pin-project-lite", - "windows-sys 0.48.0", + "windows-sys", ] [[package]] @@ -2252,13 +2236,11 @@ checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] name = "pretty_assertions" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a25e9bcb20aa780fd0bb16b72403a9064d6b3f22f026946029acb941a50af755" +checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" dependencies = [ - "ctor", "diff", - "output_vt100", "yansi", ] @@ -2389,9 +2371,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.28" +version = "1.0.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488" +checksum = "573015e8ab27661678357f27dc26460738fd2b6c86e46f386fde94cb5d913105" dependencies = [ "proc-macro2", ] @@ -2438,7 +2420,7 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] @@ -2447,7 +2429,7 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] @@ -2463,13 +2445,14 @@ dependencies = [ [[package]] name = "regex" -version = "1.8.4" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0ab3ca65655bb1e41f2a8c8cd662eb4fb035e67c3f78da1d61dffe89d07300f" +checksum = "89089e897c013b3deb627116ae56a6955a72b8bed395c9526af31c9fe528b484" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.7.2", + "regex-automata 0.3.0", + "regex-syntax 0.7.3", ] [[package]] @@ -2481,6 +2464,17 @@ dependencies = [ "regex-syntax 0.6.29", ] +[[package]] +name = "regex-automata" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa250384981ea14565685dea16a9ccc4d1c541a13f82b9c168572264d1df8c56" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.7.3", +] + [[package]] name = "regex-syntax" version = "0.6.29" @@ -2489,9 +2483,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436b050e76ed2903236f032a59761c1eb99e1b0aead2c257922771dab1fc8c78" +checksum = "2ab07dc67230e4a4718e70fd5c20055a4334b121f1f9db8fe63ef39ce9b8c846" [[package]] name = "rend" @@ -2599,16 +2593,29 @@ dependencies = [ [[package]] name = "rustix" -version = "0.37.20" +version = "0.37.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96e891d04aa506a6d1f318d2771bcb1c7dfda84e126660ace067c9b474bb2c0" +checksum = "4d69718bf81c6127a49dc64e44a742e8bb9213c0ff8869a22c308f84c1d4ab06" dependencies = [ - "bitflags", + "bitflags 1.3.2", "errno", "io-lifetimes", "libc", - "linux-raw-sys", - "windows-sys 0.48.0", + "linux-raw-sys 0.3.8", + "windows-sys", +] + +[[package]] +name = "rustix" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac5ffa1efe7548069688cd7028f32591853cd7b5b756d41bcffd2353e4fc75b4" +dependencies = [ + "bitflags 2.3.3", + "errno", + "libc", + "linux-raw-sys 0.4.3", + "windows-sys", ] [[package]] @@ -2625,13 +2632,13 @@ dependencies = [ [[package]] name = "rustls" -version = "0.21.2" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e32ca28af694bc1bbf399c33a516dbdf1c90090b8ab23c2bc24f834aa2247f5f" +checksum = "b19faa85ecb5197342b54f987b142fb3e30d0c90da40f80ef4fa9a726e6676ed" dependencies = [ "log", "ring", - "rustls-webpki", + "rustls-webpki 0.101.1", "sct", ] @@ -2649,9 +2656,9 @@ dependencies = [ [[package]] name = "rustls-pemfile" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d194b56d58803a43635bdc398cd17e383d6f71f9182b9a192c127ca42494a59b" +checksum = "2d3987094b1d07b653b7dfdc3f70ce9a1da9c51ac18c1b06b662e4f9a0e9f4b2" dependencies = [ "base64 0.21.2", ] @@ -2666,25 +2673,35 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustls-webpki" +version = "0.101.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f36a6828982f422756984e47912a7a51dcbc2a197aa791158f8ca61cd8204e" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustversion" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f3208ce4d8448b3f3e7d168a73f5e0c43a61e32930de3bceeccedb388b6bf06" +checksum = "dc31bd9b61a32c31f9650d18add92aa83a49ba979c143eefd27fe7177b05bd5f" [[package]] name = "ryu" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" +checksum = "fe232bdf6be8c8de797b22184ee71118d63780ea42ac85b61d1baa6d3b782ae9" [[package]] name = "schannel" -version = "0.1.21" +version = "0.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "713cfb06c7059f3588fb8044c0fad1d09e3c01d225e25b9220dbfdcf16dbb1b3" +checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88" dependencies = [ - "windows-sys 0.42.0", + "windows-sys", ] [[package]] @@ -2878,7 +2895,7 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc758eb7bffce5b308734e9b0c1468893cae9ff70ebf13e7090be8dcbcc83a8" dependencies = [ - "bitflags", + "bitflags 1.3.2", "core-foundation", "core-foundation-sys", "libc", @@ -2903,29 +2920,29 @@ checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed" [[package]] name = "serde" -version = "1.0.164" +version = "1.0.167" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e8c8cf938e98f769bc164923b06dce91cea1751522f46f8466461af04c9027d" +checksum = "7daf513456463b42aa1d94cff7e0c24d682b429f020b9afa4f5ba5c40a22b237" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.164" +version = "1.0.167" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9735b638ccc51c28bf6914d90a2e9725b377144fc612c49a611fddd1b631d68" +checksum = "b69b106b68bc8054f0e974e70d19984040f8a5cf9215ca82626ea4853f82c4b9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.23", ] [[package]] name = "serde_json" -version = "1.0.99" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46266871c240a00b8f503b877622fe33430b3c7d963bdc0f2adc511e54a1eae3" +checksum = "0f1e14e89be7aa4c4b78bdbdc9eb5bf8517829a600ae8eaa39a6e1d960b5185c" dependencies = [ "itoa", "ryu", @@ -2934,9 +2951,9 @@ dependencies = [ [[package]] name = "serde_path_to_error" -version = "0.1.12" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b1b6471d7496b051e03f1958802a73f88b947866f5146f329e47e36554f4e55" +checksum = "8acc4422959dd87a76cb117c191dcbffc20467f06c9100b76721dab370f24d3a" dependencies = [ "itoa", "serde", @@ -3059,9 +3076,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" +checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" [[package]] name = "smartcow" @@ -3139,7 +3156,7 @@ dependencies = [ "atoi", "base64 0.13.1", "bigdecimal", - "bitflags", + "bitflags 1.3.2", "byteorder", "bytes", "chrono", @@ -3278,9 +3295,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.22" +version = "2.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efbeae7acf4eabd6bcdcbd11c92f45231ddda7539edc7806bd1a04a03b24616" +checksum = "59fb7d6d8281a51045d62b8eb3a7d1ce347b76f312af50cd3dc0af39c87c1737" dependencies = [ "proc-macro2", "quote", @@ -3310,7 +3327,7 @@ checksum = "e71277381bd8b17eea2126a849dced540862c498398d4dd52405233a5d3cc643" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.23", ] [[package]] @@ -3321,22 +3338,22 @@ checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d" [[package]] name = "thiserror" -version = "1.0.40" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac" +checksum = "b8ed1fcd28efec0862cc76ebc8b6e9206585521af7bd3259a987ffc858f89947" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.40" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" +checksum = "f85e0dcc42d16c4f19503b2dcdb062d2466efc133b12ffef7dbe02e6ee37e991" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.23", ] [[package]] @@ -3410,7 +3427,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.48.0", + "windows-sys", ] [[package]] @@ -3421,7 +3438,7 @@ checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.23", ] [[package]] @@ -3463,9 +3480,9 @@ checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" [[package]] name = "toml_edit" -version = "0.19.11" +version = "0.19.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "266f016b7f039eec8a1a80dfe6156b633d208b9fccca5e4db1d6775b0c4e34a7" +checksum = "c500344a19072298cd05a7224b3c0c629348b78692bf48466c5238656e315a78" dependencies = [ "indexmap 2.0.0", "toml_datetime", @@ -3493,7 +3510,7 @@ checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.23", ] [[package]] @@ -3686,7 +3703,7 @@ checksum = "6d19bf7f37bc3e66beae9792c0f7a0e3500465cf431da63fdb6af593b0e353a4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.23", ] [[package]] @@ -3729,10 +3746,10 @@ checksum = "3016aa92cfc68551ad7291c2f0dee6bb87f1bd08133d61ca93e61cebd1d3b4ce" dependencies = [ "async-rustls", "log", - "rustls 0.21.2", + "rustls 0.21.3", "rustls-native-certs", "rustls-pemfile", - "rustls-webpki", + "rustls-webpki 0.100.1", "trillium-server-common", "webpki-roots 0.23.1", ] @@ -3850,9 +3867,9 @@ checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" [[package]] name = "unicode-ident" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" +checksum = "22049a19f4a68748a168c0fc439f9516686aa045927ff767eca0a85101fb6e73" [[package]] name = "unicode-normalization" @@ -4035,7 +4052,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.23", "wasm-bindgen-shared", ] @@ -4069,7 +4086,7 @@ checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.22", + "syn 2.0.23", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -4115,7 +4132,7 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b03058f88386e5ff5310d9111d53f48b17d732b401aeb83a8d5190f2ac459338" dependencies = [ - "rustls-webpki", + "rustls-webpki 0.100.1", ] [[package]] @@ -4168,21 +4185,6 @@ dependencies = [ "windows-targets", ] -[[package]] -name = "windows-sys" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-sys" version = "0.48.0" @@ -4194,97 +4196,55 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.48.0" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f" dependencies = [ - "windows_aarch64_gnullvm 0.48.0", - "windows_aarch64_msvc 0.48.0", - "windows_i686_gnu 0.48.0", - "windows_i686_msvc 0.48.0", - "windows_x86_64_gnu 0.48.0", - "windows_x86_64_gnullvm 0.48.0", - "windows_x86_64_msvc 0.48.0", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.48.0" @@ -4293,9 +4253,9 @@ checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" [[package]] name = "winnow" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca0ace3845f0d96209f0375e6d367e3eb87eb65d27d445bdc9f1843a26f39448" +checksum = "a9482fe6ceabdf32f3966bfdd350ba69256a97c30253dc616fe0005af24f164e" dependencies = [ "memchr", ] diff --git a/app/src/ApiClient.ts b/app/src/ApiClient.ts index 70177a94..c9fc8b9f 100644 --- a/app/src/ApiClient.ts +++ b/app/src/ApiClient.ts @@ -112,11 +112,20 @@ export interface NewAggregator { bearer_token: string; } +export interface ApiToken { + id: string; + account_id: string; + token_hash: string; + created_at: string; + deleted_at?: string; + name?: string; + last_used_at?: string; +} + const mime = "application/vnd.divviup+json;version=0.1"; export class ApiClient { private client?: Promise | AxiosInstance; - currentUser?: User; static async fetchBaseUrl(): Promise { let url = new URL(window.location.href); @@ -135,7 +144,7 @@ export class ApiClient { Accept: mime, }, validateStatus(status) { - return status >= 200 && status < 500; + return (status >= 200 && status < 300) || status == 400; }, }); } @@ -151,22 +160,19 @@ export class ApiClient { return (await this.populateClient()).getUri({ url: "/login" }); } - async logoutUrl(): Promise { - return (await this.populateClient()).getUri({ url: "/logout" }); + async redirectToLogin(): Promise { + let loginUrl = await this.loginUrl(); + window.location.href = loginUrl; + return null; } - isLoggedIn(): boolean { - return !!this.currentUser; + async logoutUrl(): Promise { + return (await this.populateClient()).getUri({ url: "/logout" }); } async getCurrentUser(): Promise { - if (this.currentUser) { - return this.currentUser; - } - let client = await this.populateClient(); - let res = await client.get("/api/users/me"); - this.currentUser = res.data as User; - return this.currentUser; + let res = await this.get("/api/users/me"); + return res.data as User; } private async get(path: string): Promise { @@ -174,7 +180,7 @@ export class ApiClient { return client.get(path); } - private async post(path: string, body: unknown): Promise { + private async post(path: string, body?: unknown): Promise { let client = await this.populateClient(); return client.post(path, body); } @@ -302,6 +308,31 @@ export class ApiClient { } } + async accountApiTokens(accountId: string): Promise { + const res = await this.get(`/api/accounts/${accountId}/api_tokens`); + return res.data as ApiToken[]; + } + + async createApiToken( + accountId: string + ): Promise { + const res = await this.post(`/api/accounts/${accountId}/api_tokens`); + return res.data as ApiToken & { token: string }; + } + + async deleteApiToken(tokenId: string): Promise { + await this.delete(`/api/api_tokens/${tokenId}`); + return null; + } + + async updateApiToken( + tokenId: string, + token: { name: string } + ): Promise { + await this.patch(`/api/api_tokens/${tokenId}`, token); + return null; + } + async queue(searchParams: URLSearchParams): Promise { const res = await this.get(`/api/admin/queue?${searchParams}`); return res.data as QueueJob[]; @@ -377,8 +408,8 @@ export interface FormikLikeErrors { export type ValidationErrorsFor = { [K in keyof T]?: T[K] extends object - ? ValidationErrorsFor - : ValidationError[]; + ? ValidationErrorsFor + : ValidationError[]; }; export interface ValidationError { diff --git a/app/src/AccountForm.tsx b/app/src/accounts/AccountForm.tsx similarity index 100% rename from app/src/AccountForm.tsx rename to app/src/accounts/AccountForm.tsx diff --git a/app/src/AccountList.tsx b/app/src/accounts/AccountList.tsx similarity index 89% rename from app/src/AccountList.tsx rename to app/src/accounts/AccountList.tsx index 339d99c2..8a31ee4b 100644 --- a/app/src/AccountList.tsx +++ b/app/src/accounts/AccountList.tsx @@ -4,10 +4,10 @@ import Col from "react-bootstrap/Col"; import Breadcrumb from "react-bootstrap/Breadcrumb"; import React from "react"; import { useLoaderData, useAsyncValue, Await } from "react-router-dom"; -import { Account } from "./ApiClient"; +import { Account } from "../ApiClient"; import ListGroup from "react-bootstrap/ListGroup"; import { LinkContainer } from "react-router-bootstrap"; -import { Button } from "react-bootstrap"; +import { Button, Placeholder } from "react-bootstrap"; import { BuildingAdd } from "react-bootstrap-icons"; function Breadcrumbs() { @@ -49,7 +49,9 @@ export default function AccountList() { - Loading + + + } > diff --git a/app/src/AccountSummary.tsx b/app/src/accounts/AccountSummary.tsx similarity index 87% rename from app/src/AccountSummary.tsx rename to app/src/accounts/AccountSummary.tsx index a20e7f08..8dc4a0bd 100644 --- a/app/src/AccountSummary.tsx +++ b/app/src/accounts/AccountSummary.tsx @@ -2,16 +2,8 @@ import Breadcrumb from "react-bootstrap/Breadcrumb"; import Col from "react-bootstrap/Col"; import Row from "react-bootstrap/Row"; import ListGroup from "react-bootstrap/ListGroup"; -import { - Await, - Form, - useActionData, - useAsyncValue, - useRouteLoaderData, -} from "react-router-dom"; +import { Form, useActionData } from "react-router-dom"; import { Suspense, useCallback, useEffect, useState } from "react"; -import { Account } from "./ApiClient"; -import Spinner from "react-bootstrap/Spinner"; import { LinkContainer } from "react-router-bootstrap"; import { Building, @@ -19,9 +11,11 @@ import { FileEarmarkCode, PencilFill, People, + ShieldLock, } from "react-bootstrap-icons"; import { Button, FormControl, InputGroup } from "react-bootstrap"; -import { WithAccount } from "./util"; +import { WithAccount } from "../util"; +import Placeholder from "react-bootstrap/Placeholder"; function AccountName() { let [isEditingName, setIsEditingName] = useState(false); @@ -72,7 +66,7 @@ function AccountName() {

- + }> {(account) => account.name}

@@ -100,7 +94,7 @@ export default function AccountSummary() { Accounts - + }> {(account) => account.name} @@ -128,6 +122,12 @@ export default function AccountSummary() { Aggregators + + + + API Tokens + + diff --git a/app/src/accounts/index.tsx b/app/src/accounts/index.tsx new file mode 100644 index 00000000..e2b5c5e5 --- /dev/null +++ b/app/src/accounts/index.tsx @@ -0,0 +1,81 @@ +import { RouteObject, defer, redirect } from "react-router-dom"; +import ApiClient, { PartialAccount } from "../ApiClient"; +import AccountSummary from "./AccountSummary"; +import AccountForm from "./AccountForm"; +import AccountList from "./AccountList"; + +export default function accounts( + apiClient: ApiClient, + children: RouteObject[] +): RouteObject { + return { + path: "accounts", + + children: [ + { + path: "", + element: , + loader() { + return defer({ accounts: apiClient.accounts() }); + }, + index: true, + }, + { + path: ":account_id", + id: "account", + loader({ params }) { + return defer({ + account: apiClient.account(params.account_id as string), + }); + }, + + async action({ params, request }) { + let data = Object.fromEntries(await request.formData()); + switch (request.method) { + case "PATCH": + return { + account: await apiClient.updateAccount( + params.account_id as string, + data as unknown as PartialAccount + ), + }; + default: + throw new Error(`unexpected method ${request.method}`); + } + }, + + shouldRevalidate(args) { + return ( + typeof args.actionResult === "object" && + args.actionResult !== null && + "account" in args.actionResult + ); + }, + children: [ + { + path: "", + element: , + }, + { + path: "new", + element: , + async action({ request }) { + let data = Object.fromEntries(await request.formData()); + switch (request.method) { + case "POST": + const account = await apiClient.createAccount( + data as unknown as PartialAccount + ); + return redirect(`/accounts/${account.id}`); + default: + throw new Error(`unexpected method ${request.method}`); + } + }, + }, + + ...children, + ], + }, + ], + }; +} diff --git a/app/src/admin/index.tsx b/app/src/admin/index.tsx new file mode 100644 index 00000000..5f759aad --- /dev/null +++ b/app/src/admin/index.tsx @@ -0,0 +1,33 @@ +import { RouteObject } from "react-router-dom"; +import ApiClient from "../ApiClient"; + +export default function admin(apiClient: ApiClient): RouteObject { + return { + path: "admin", + children: [ + { + path: "queue", + async lazy() { + return import("../admin/Queue"); + }, + async loader({ request }) { + const params = new URL(request.url).searchParams; + return apiClient.queue(params); + }, + children: [ + { + path: ":job_id", + async lazy() { + return import("../admin/QueueJob"); + }, + + async loader({ params }) { + if ("job_id" in params && typeof params.job_id === "string") + return apiClient.queueJob(params.job_id); + }, + }, + ], + }, + ], + }; +} diff --git a/app/src/AggregatorDetail.tsx b/app/src/aggregators/AggregatorDetail.tsx similarity index 87% rename from app/src/AggregatorDetail.tsx rename to app/src/aggregators/AggregatorDetail.tsx index a052f773..0d97aa77 100644 --- a/app/src/AggregatorDetail.tsx +++ b/app/src/aggregators/AggregatorDetail.tsx @@ -1,6 +1,6 @@ import { Await, useLoaderData, useParams } from "react-router-dom"; -import { Aggregator } from "./ApiClient"; -import { AccountBreadcrumbs } from "./util"; +import { Aggregator } from "../ApiClient"; +import { AccountBreadcrumbs } from "../util"; import { LinkContainer } from "react-router-bootstrap"; import Breadcrumb from "react-bootstrap/Breadcrumb"; import React, { Suspense } from "react"; @@ -8,7 +8,8 @@ import Row from "react-bootstrap/Row"; import Col from "react-bootstrap/Col"; import { CloudUpload } from "react-bootstrap-icons"; import Table from "react-bootstrap/Table"; -import D from "./logo/color/svg/small.svg"; +import D from "../logo/color/svg/small.svg"; +import Placeholder from "react-bootstrap/Placeholder"; function Breadcrumbs() { let { aggregator } = useLoaderData() as { @@ -22,7 +23,7 @@ function Breadcrumbs() { Aggregators - + }> {(aggregator) => aggregator.name} @@ -44,7 +45,7 @@ export default function AggregatorDetail() { - {" ..."} + } > @@ -121,7 +122,7 @@ export function WithAggregator({ }; return ( - + }> ); diff --git a/app/src/AggregatorForm.tsx b/app/src/aggregators/AggregatorForm.tsx similarity index 95% rename from app/src/AggregatorForm.tsx rename to app/src/aggregators/AggregatorForm.tsx index 460fa27e..4f5bd3fd 100644 --- a/app/src/AggregatorForm.tsx +++ b/app/src/aggregators/AggregatorForm.tsx @@ -2,7 +2,7 @@ import Breadcrumb from "react-bootstrap/Breadcrumb"; import Row from "react-bootstrap/Row"; import Col from "react-bootstrap/Col"; import Button from "react-bootstrap/Button"; -import { AccountBreadcrumbs, WithAccount } from "./util"; +import { AccountBreadcrumbs, WithAccount } from "../util"; import { CloudUpload } from "react-bootstrap-icons"; import React from "react"; import { LinkContainer } from "react-router-bootstrap"; @@ -12,7 +12,7 @@ import FormControl from "react-bootstrap/FormControl"; import FormGroup from "react-bootstrap/FormGroup"; import FormLabel from "react-bootstrap/FormLabel"; import FormSelect from "react-bootstrap/FormSelect"; -import ApiClient, { NewAggregator, formikErrors } from "./ApiClient"; +import ApiClient, { NewAggregator, formikErrors } from "../ApiClient"; import { NavigateFunction, useActionData, @@ -20,8 +20,9 @@ import { useNavigation, useParams, } from "react-router-dom"; -import { ApiClientContext } from "./ApiClientContext"; +import { ApiClientContext } from "../ApiClientContext"; const { Suspense } = React; +import Placeholder from "react-bootstrap/Placeholder"; async function submit( apiClient: ApiClient, @@ -67,7 +68,7 @@ export default function AggregatorForm() {

{" "} - + }> {(account) => account.name} {" "} Aggregators diff --git a/app/src/AggregatorList.tsx b/app/src/aggregators/AggregatorList.tsx similarity index 88% rename from app/src/AggregatorList.tsx rename to app/src/aggregators/AggregatorList.tsx index 5d25fdc7..3beca9b8 100644 --- a/app/src/AggregatorList.tsx +++ b/app/src/aggregators/AggregatorList.tsx @@ -2,14 +2,15 @@ import Breadcrumb from "react-bootstrap/Breadcrumb"; import Row from "react-bootstrap/Row"; import Col from "react-bootstrap/Col"; import Button from "react-bootstrap/Button"; -import { AccountBreadcrumbs, WithAccount } from "./util"; +import { AccountBreadcrumbs, WithAccount } from "../util"; import { CloudUpload } from "react-bootstrap-icons"; import { Suspense } from "react"; import { LinkContainer } from "react-router-bootstrap"; import { Await, useLoaderData } from "react-router-dom"; -import { Aggregator } from "./ApiClient"; +import { Aggregator } from "../ApiClient"; import { ListGroup } from "react-bootstrap"; -import D from "./logo/color/svg/small.svg"; +import D from "../logo/color/svg/small.svg"; +import Placeholder from "react-bootstrap/Placeholder"; export default function Aggregators() { return ( @@ -19,7 +20,7 @@ export default function Aggregators() {

{" "} - + }> {(account) => account.name} {" "} Aggregators diff --git a/app/src/aggregators/index.tsx b/app/src/aggregators/index.tsx new file mode 100644 index 00000000..009ebdeb --- /dev/null +++ b/app/src/aggregators/index.tsx @@ -0,0 +1,52 @@ +import Aggregators from "./AggregatorList"; +import AggregatorForm from "./AggregatorForm"; +import AggregatorDetail from "./AggregatorDetail"; +import ApiClient from "../ApiClient"; +import { RouteObject, defer } from "react-router-dom"; + +export default function aggregators(apiClient: ApiClient): RouteObject { + return { + path: "aggregators", + children: [ + { + path: "", + index: true, + element: , + loader({ params }) { + return defer({ + aggregators: apiClient.accountAggregators( + params.account_id as string + ), + }); + }, + }, + { + path: ":aggregator_id", + element: , + loader({ params }) { + return defer({ + aggregator: apiClient.aggregator(params.aggregator_id as string), + }); + }, + + async action({ params, request }) { + let data = Object.fromEntries(await request.formData()); + switch (request.method) { + case "PATCH": + return await apiClient.updateTask( + params.task_id as string, + data as { name: string } + ); + default: + throw new Error(`unexpected method ${request.method}`); + } + }, + }, + + { + path: "new", + element: , + }, + ], + }; +} diff --git a/app/src/api-tokens/ApiTokenList.tsx b/app/src/api-tokens/ApiTokenList.tsx new file mode 100644 index 00000000..86422254 --- /dev/null +++ b/app/src/api-tokens/ApiTokenList.tsx @@ -0,0 +1,265 @@ +import Breadcrumb from "react-bootstrap/Breadcrumb"; +import Row from "react-bootstrap/Row"; +import Col from "react-bootstrap/Col"; +import Button from "react-bootstrap/Button"; +import { AccountBreadcrumbs, WithAccount } from "../util"; +import { + Check, + Clipboard, + ClipboardCheckFill, + PencilFill, + ShieldLock, + ShieldLockFill, + Trash, +} from "react-bootstrap-icons"; +import { Suspense, useCallback, useEffect, useState } from "react"; +import { + Await, + Form, + useActionData, + useFetcher, + useLoaderData, + useNavigation, +} from "react-router-dom"; +import { ApiToken } from "../ApiClient"; +import Table from "react-bootstrap/Table"; +import React from "react"; +import { DateTime } from "luxon"; +import FormControl from "react-bootstrap/FormControl"; +import FormGroup from "react-bootstrap/FormGroup"; +import InputGroup from "react-bootstrap/InputGroup"; +import Modal from "react-bootstrap/Modal"; +import Placeholder from "react-bootstrap/Placeholder"; + +export default function ApiTokens() { + const navigation = useNavigation(); + return ( + <> + + + +

+ {" "} + }> + {(account) => account.name} + {" "} + API Tokens +

+ +
+ + +
+ +
+ +
+ + + + + + + ); +} + +function Breadcrumbs() { + return ( + + ApiTokens + + ); +} + +function ApiTokenList() { + let { apiTokens } = useLoaderData() as { + apiTokens: Promise; + }; + + return ( + + + + + + + + + + + + + + {(apiTokens: ApiToken[]) => + apiTokens.map((apiToken) => ( + + )) + } + + + +
Token NameLast UsedCreated
+ ); +} + +function TokenName({ apiToken }: { apiToken: ApiToken }) { + let [isEditing, setEditing] = useState(false); + let edit = useCallback(() => setEditing(true), [setEditing]); + let fetcher = useFetcher(); + useEffect(() => { + if (fetcher.data) setEditing(false); + }, [fetcher, setEditing]); + if (isEditing) { + return ( + + + + + + + + + ); + } else { + return ( + + {apiToken.name || `Token ${apiToken.token_hash.slice(0, 5)}`}{" "} + + + ); + } +} + +function RelativeTime({ time, missing }: { time?: string; missing?: string }) { + return time ? ( + + {DateTime.fromISO(time).toLocal().toLocaleString(DateTime.DATETIME_SHORT)} + + ) : ( + <>{missing || "never"} + ); +} + +function DeleteButton({ apiToken }: { apiToken: ApiToken }) { + const navigation = useNavigation(); + + const [show, setShow] = useState(false); + const close = React.useCallback(() => setShow(false), []); + const open = React.useCallback(() => setShow(true), []); + const fetcher = useFetcher(); + + useEffect(() => { + if (fetcher.data) close(); + }, [fetcher, close]); + + return ( + <> + + + + Confirm Token Deletion {apiToken.name} + + + This token will immediately be revoked.{" "} + {apiToken.last_used_at ? ( + <> + It was last used + + ) : ( + <>It has never been used + )} + + + + + + + + + + ); +} + +function Token({ token }: { token: string | null }) { + if (!token) return null; + const [copied, setCopied] = useState(false); + const copy = useCallback(() => { + navigator.clipboard.writeText(token).then(() => { + setCopied(true); + }); + }, [setCopied, token]); + + return ( + <> + + {token}{" "} + + + + ); +} + +function ApiTokenRow({ apiToken }: { apiToken: ApiToken }) { + const actionData = useActionData() as + | undefined + | (ApiToken & { token: string }); + const token = actionData?.id == apiToken.id ? actionData?.token : null; + + return ( + + + + + + + + + + + + + + + + + + ); +} diff --git a/app/src/api-tokens/index.tsx b/app/src/api-tokens/index.tsx new file mode 100644 index 00000000..8b3c7e03 --- /dev/null +++ b/app/src/api-tokens/index.tsx @@ -0,0 +1,58 @@ +import { RouteObject, defer } from "react-router-dom"; +import ApiClient from "../ApiClient"; +import ApiTokenList from "./ApiTokenList"; +export default function apiTokens(apiClient: ApiClient): RouteObject { + return { + path: "api_tokens", + children: [ + { + path: "", + index: true, + element: , + loader({ params }) { + return defer({ + apiTokens: apiClient + .accountApiTokens(params.account_id as string) + .then((tokens) => tokens.reverse()), + }); + }, + + shouldRevalidate(_) { + return true; + }, + + async action({ params, request }) { + switch (request.method) { + case "POST": + return await apiClient.createApiToken( + params.account_id as string + ); + default: + throw new Error(`unexpected method ${request.method}`); + } + }, + }, + + { + path: ":api_token_id", + async action({ params, request }) { + switch (request.method) { + case "PATCH": + await apiClient.updateApiToken( + params.api_token_id as string, + Object.fromEntries(await request.formData()) as { + name: string; + } + ); + return true; + case "DELETE": + await apiClient.deleteApiToken(params.api_token_id as string); + return true; + default: + throw new Error(`unexpected method ${request.method}`); + } + }, + }, + ], + }; +} diff --git a/app/src/ErrorPage.tsx b/app/src/layout/ErrorPage.tsx similarity index 72% rename from app/src/ErrorPage.tsx rename to app/src/layout/ErrorPage.tsx index 5bbe48c4..4b057766 100644 --- a/app/src/ErrorPage.tsx +++ b/app/src/layout/ErrorPage.tsx @@ -1,12 +1,18 @@ import { AxiosError } from "axios"; import Alert from "react-bootstrap/Alert"; -import { isRouteErrorResponse, useRouteError } from "react-router"; +import { + isRouteErrorResponse, + useRouteError, + useRouteLoaderData, +} from "react-router-dom"; import ApiClient from "./ApiClient"; import Layout from "./Layout"; export default function ErrorPage({ apiClient }: { apiClient: ApiClient }) { const error = useRouteError(); - + let { currentUser } = useRouteLoaderData("currentUser") as { + currentUser: Promise; + }; if (error instanceof AxiosError) { switch (error.response?.status) { case 403: @@ -27,7 +33,6 @@ export default function ErrorPage({ apiClient }: { apiClient: ApiClient }) { return ( - {" "}

Whoops!

{body}

@@ -44,12 +49,20 @@ export default function ErrorPage({ apiClient }: { apiClient: ApiClient }) { ); } + console.error(error); + return ( <>

Whoops!

-

{error as unknown as string}

-        {JSON.stringify(error, null, 2)}
+        
+          {typeof error === "object" &&
+          error &&
+          "stack" in error &&
+          typeof error.stack === "string"
+            ? error.stack
+            : null}
+        
       
); diff --git a/app/src/Header.tsx b/app/src/layout/Header.tsx similarity index 66% rename from app/src/Header.tsx rename to app/src/layout/Header.tsx index 3cf10415..fe329b1a 100644 --- a/app/src/Header.tsx +++ b/app/src/layout/Header.tsx @@ -1,10 +1,15 @@ -import { Await, useLoaderData, Link, useAsyncValue } from "react-router-dom"; +import { + Await, + Link, + useAsyncValue, + useRouteLoaderData, +} from "react-router-dom"; import Container from "react-bootstrap/Container"; import Navbar from "react-bootstrap/Navbar"; -import { User } from "./ApiClient"; +import { User } from "../ApiClient"; import { Suspense } from "react"; import NavDropdown from "react-bootstrap/NavDropdown"; -import logo from "./logo/color/svg/cropped.svg"; +import logo from "../logo/color/svg/cropped.svg"; import { LinkContainer } from "react-router-bootstrap"; import { Nav } from "react-bootstrap"; @@ -52,24 +57,14 @@ function LoggedInHeader() { } export default function Header() { - let loaderData = useLoaderData(); - let currentUser: Promise | undefined; - if ( - typeof loaderData === "object" && - loaderData && - "currentUser" in loaderData - ) { - currentUser = loaderData.currentUser as Promise; - } - if (currentUser) { - return ( - }> - - {(user) => (user ? : )} - - - ); - } else { - return ; - } + let { currentUser } = useRouteLoaderData("currentUser") as { + currentUser: Promise; + }; + return ( + }> + + {(user) => (user ? : )} + + + ); } diff --git a/app/src/Layout.tsx b/app/src/layout/Layout.tsx similarity index 100% rename from app/src/Layout.tsx rename to app/src/layout/Layout.tsx diff --git a/app/src/layout/index.tsx b/app/src/layout/index.tsx new file mode 100644 index 00000000..79f4ab29 --- /dev/null +++ b/app/src/layout/index.tsx @@ -0,0 +1,31 @@ +import { RouteObject, redirect } from "react-router-dom"; +import ApiClient from "../ApiClient"; +import ErrorPage from "./ErrorPage"; +import { AxiosError } from "axios"; +import Layout from "./Layout"; + +export default function layout( + apiClient: ApiClient, + children: RouteObject[] +): RouteObject { + return { + path: "/", + element: , + id: "currentUser", + async loader() { + try { + const currentUser = await apiClient.getCurrentUser(); + return { currentUser }; + } catch (e) { + if (e instanceof AxiosError && e.response?.status === 403) { + return await apiClient.redirectToLogin(); + } else throw e; + } + }, + shouldRevalidate(_) { + return false; + }, + errorElement: , + children, + }; +} diff --git a/app/src/Memberships.tsx b/app/src/memberships/Memberships.tsx similarity index 95% rename from app/src/Memberships.tsx rename to app/src/memberships/Memberships.tsx index 16c1a5b9..d128808c 100644 --- a/app/src/Memberships.tsx +++ b/app/src/memberships/Memberships.tsx @@ -10,11 +10,12 @@ import { useSubmit, } from "react-router-dom"; import React, { Suspense, useState } from "react"; -import { Membership, User } from "./ApiClient"; +import { Membership, User } from "../ApiClient"; import { Button, FormControl } from "react-bootstrap"; import { PersonSlash, PersonAdd, People } from "react-bootstrap-icons"; import Modal from "react-bootstrap/Modal"; -import { AccountBreadcrumbs, WithAccount } from "./util"; +import { AccountBreadcrumbs, WithAccount } from "../util"; +import Placeholder from "react-bootstrap/Placeholder"; function Breadcrumbs() { return ( @@ -121,7 +122,7 @@ function MembershipList() { <>

{" "} - + }> {(account) => account.name} {" "} Members diff --git a/app/src/memberships/index.tsx b/app/src/memberships/index.tsx new file mode 100644 index 00000000..3d9597ea --- /dev/null +++ b/app/src/memberships/index.tsx @@ -0,0 +1,35 @@ +import { RouteObject, defer } from "react-router-dom"; +import ApiClient from "../ApiClient"; +import Memberships from "./Memberships"; + +export default function memberships(apiClient: ApiClient): RouteObject { + return { + path: "memberships", + element: , + loader({ params }) { + return defer({ + memberships: apiClient.accountMemberships(params.account_id as string), + }); + }, + + shouldRevalidate(_) { + return true; + }, + + async action({ params, request }) { + let data = Object.fromEntries(await request.formData()); + switch (request.method) { + case "DELETE": + await apiClient.deleteMembership(data.membershipId as string); + return { deleted: data.membershipId }; + case "POST": + return await apiClient.createMembership( + params.account_id as string, + data as { user_email: string } + ); + default: + throw new Error(`unexpected method ${request.method}`); + } + }, + }; +} diff --git a/app/src/router.tsx b/app/src/router.tsx index d61f72b6..2419f210 100644 --- a/app/src/router.tsx +++ b/app/src/router.tsx @@ -2,299 +2,33 @@ import React from "react"; import { createBrowserRouter, RouterProvider, - defer, + RouteObject, redirect, } from "react-router-dom"; import { ApiClientContext } from "./ApiClientContext"; -import { ApiClient, PartialAccount } from "./ApiClient"; -import AccountForm from "./AccountForm"; -import AccountList from "./AccountList"; -import Layout from "./Layout"; -import TaskList from "./TaskList"; -import Memberships from "./Memberships"; -import AccountSummary from "./AccountSummary"; -import TaskForm from "./TaskForm"; -import TaskDetail from "./TaskDetail"; -import Aggregators from "./AggregatorList"; -import { AxiosError } from "axios"; -import ErrorPage from "./ErrorPage"; -import AggregatorForm from "./AggregatorForm"; -import AggregatorDetail from "./AggregatorDetail"; - -function Login({ apiClient }: { apiClient: ApiClient }) { - apiClient.loginUrl().then((url) => { - window.location.href = url; - }); - return <>; -} - -function Logout({ apiClient }: { apiClient: ApiClient }) { - apiClient.logoutUrl().then((url) => { - window.location.href = url; - }); - return <>; -} +import { ApiClient } from "./ApiClient"; +import layout from "./layout"; +import admin from "./admin"; +import memberships from "./memberships"; +import tasks from "./tasks"; +import accounts from "./accounts"; +import apiTokens from "./api-tokens"; +import aggregators from "./aggregators"; +import { Spinner } from "react-bootstrap"; function buildRouter(apiClient: ApiClient) { return createBrowserRouter([ - { - path: "/", - element: , - id: "currentUser", - loader: async () => ({ - currentUser: await apiClient.getCurrentUser().catch((e: unknown) => { - if (e instanceof AxiosError) { - if (e.response?.status === 403) return null; - } - }), - }), - shouldRevalidate(_) { - return false; - }, - - errorElement: , - - children: [ - { - path: "", - loader() { - return redirect("/accounts"); - }, - index: true, - }, - { - path: "admin", - children: [ - { - path: "queue", - async lazy() { - return import("./admin/Queue"); - }, - async loader({ request }) { - const params = new URL(request.url).searchParams; - return apiClient.queue(params); - }, - children: [ - { - path: ":job_id", - async lazy() { - return import("./admin/QueueJob"); - }, - - async loader({ params }) { - if ("job_id" in params && typeof params.job_id === "string") - return apiClient.queueJob(params.job_id); - }, - }, - ], - }, - ], - }, - - { - path: "login", - element: , - }, - { path: "logout", element: }, - { - path: "accounts", - - children: [ - { - path: "", - element: , - loader() { - return defer({ accounts: apiClient.accounts() }); - }, - index: true, - }, - { - path: ":account_id", - id: "account", - loader({ params }) { - return defer({ - account: apiClient.account(params.account_id as string), - }); - }, - - async action({ params, request }) { - let data = Object.fromEntries(await request.formData()); - switch (request.method) { - case "PATCH": - return { - account: await apiClient.updateAccount( - params.account_id as string, - data as unknown as PartialAccount - ), - }; - default: - throw new Error(`unexpected method ${request.method}`); - } - }, - - shouldRevalidate(args) { - return ( - typeof args.actionResult === "object" && - args.actionResult !== null && - "account" in args.actionResult - ); - }, - - children: [ - { - path: "", - element: , - }, - - { - path: "aggregators", - element: , - loader({ params }) { - return defer({ - aggregators: apiClient.accountAggregators( - params.account_id as string - ), - }); - }, - }, - { - path: "aggregators/:aggregator_id", - element: , - loader({ params }) { - return defer({ - aggregator: apiClient.aggregator( - params.aggregator_id as string - ), - }); - }, - - async action({ params, request }) { - let data = Object.fromEntries(await request.formData()); - switch (request.method) { - case "PATCH": - return await apiClient.updateTask( - params.task_id as string, - data as { name: string } - ); - default: - throw new Error(`unexpected method ${request.method}`); - } - }, - }, - - { - path: "aggregators/new", - element: , - }, - { - path: "memberships", - element: , - loader({ params }) { - return defer({ - memberships: apiClient.accountMemberships( - params.account_id as string - ), - }); - }, - - shouldRevalidate(_) { - return true; - }, - - async action({ params, request }) { - let data = Object.fromEntries(await request.formData()); - switch (request.method) { - case "DELETE": - await apiClient.deleteMembership( - data.membershipId as string - ); - return { deleted: data.membershipId }; - case "POST": - return await apiClient.createMembership( - params.account_id as string, - data as { user_email: string } - ); - default: - throw new Error(`unexpected method ${request.method}`); - } - }, - }, - { - path: "tasks", - element: , - loader({ params }) { - return defer({ - tasks: apiClient.accountTasks( - params.account_id as string - ), - }); - }, - }, - { - path: "tasks/:task_id", - element: , - loader({ params }) { - let task = apiClient.task(params.task_id as string); - let leaderAggregator = task.then((t) => - apiClient.aggregator(t.leader_aggregator_id) - ); - let helperAggregator = task.then((t) => - apiClient.aggregator(t.helper_aggregator_id) - ); - return defer({ - task, - leaderAggregator, - helperAggregator, - }); - }, - - async action({ params, request }) { - let data = Object.fromEntries(await request.formData()); - switch (request.method) { - case "PATCH": - return await apiClient.updateTask( - params.task_id as string, - data as { name: string } - ); - default: - throw new Error(`unexpected method ${request.method}`); - } - }, - }, - - { - path: "tasks/new", - element: , - loader({ params }) { - return defer({ - aggregators: apiClient.accountAggregators( - params.account_id as string - ), - }); - }, - }, - ], - }, - { - path: "new", - element: , - async action({ request }) { - let data = Object.fromEntries(await request.formData()); - switch (request.method) { - case "POST": - const account = await apiClient.createAccount( - data as unknown as PartialAccount - ); - return redirect(`/accounts/${account.id}`); - default: - throw new Error(`unexpected method ${request.method}`); - } - }, - }, - ], - }, - ], - }, + layout(apiClient, [ + logout(apiClient), + root(apiClient), + admin(apiClient), + accounts(apiClient, [ + aggregators(apiClient), + apiTokens(apiClient), + memberships(apiClient), + tasks(apiClient), + ]), + ]), ]); } @@ -303,3 +37,24 @@ export default function Router() { let router = React.useMemo(() => buildRouter(apiClient), [apiClient]); return ; } + +function root(_apiClient: ApiClient): RouteObject { + return { + path: "", + async loader() { + return redirect("/accounts"); + }, + index: true, + }; +} + +function logout(apiClient: ApiClient): RouteObject { + return { + path: "logout", + element: , + async loader() { + window.location.href = await apiClient.logoutUrl(); + return null; + }, + }; +} diff --git a/app/src/TaskDetail.tsx b/app/src/tasks/TaskDetail.tsx similarity index 90% rename from app/src/TaskDetail.tsx rename to app/src/tasks/TaskDetail.tsx index 6bc33ab0..f82f3214 100644 --- a/app/src/TaskDetail.tsx +++ b/app/src/tasks/TaskDetail.tsx @@ -12,7 +12,7 @@ import Col from "react-bootstrap/Col"; import React, { Suspense, useCallback, useEffect, useState } from "react"; import Row from "react-bootstrap/Row"; import { LinkContainer } from "react-router-bootstrap"; -import { Task, Account, Aggregator } from "./ApiClient"; +import { Task, Aggregator } from "../ApiClient"; import humanizeDuration from "humanize-duration"; import { FileEarmarkBarGraph, @@ -27,12 +27,12 @@ import { import Button from "react-bootstrap/Button"; import Card from "react-bootstrap/Card"; import ListGroup from "react-bootstrap/ListGroup"; -import Spinner from "react-bootstrap/Spinner"; import FormControl from "react-bootstrap/FormControl"; import InputGroup from "react-bootstrap/InputGroup"; import { DateTime } from "luxon"; import "@github/relative-time-element"; -import { AccountBreadcrumbs } from "./util"; +import { AccountBreadcrumbs } from "../util"; +import Placeholder from "react-bootstrap/Placeholder"; function TaskTitle() { let { task } = useLoaderData() as { @@ -155,7 +155,7 @@ function Breadcrumbs() { Tasks - + }> {(task) => task.name} @@ -193,7 +193,7 @@ function TaskPropertyTable() { Task Id:{" "} - + }> {(task) => task.id} @@ -201,7 +201,7 @@ function TaskPropertyTable() { Time Precision:{" "} - + }> {(task) => humanizeDuration(1000 * task.time_precision_seconds)} @@ -209,7 +209,7 @@ function TaskPropertyTable() { Query Type:{" "} - + }> {(task) => typeof task.max_batch_size === "number" @@ -221,19 +221,19 @@ function TaskPropertyTable() { Minimum Batch Size:{" "} - + }> {(task) => task.min_batch_size} Expires:{" "} - + }> {(task) => task.expiration ? DateTime.fromISO(task.expiration) - .toLocal() - .toLocaleString(DateTime.DATETIME_SHORT) + .toLocal() + .toLocaleString(DateTime.DATETIME_SHORT) : "never" } @@ -241,7 +241,7 @@ function TaskPropertyTable() { Leader:{" "} - + }> {(aggregator) => ( Helper:{" "} - + }> {(aggregator) => ( Created:{" "} - + }> {(task) => DateTime.fromISO(task.created_at) @@ -279,7 +279,7 @@ function TaskPropertyTable() { - + }> {(task) => } @@ -334,7 +334,7 @@ function Metrics() { Last updated{" "} - + }> {(task) => ( diff --git a/app/src/TaskForm.tsx b/app/src/tasks/TaskForm.tsx similarity index 98% rename from app/src/TaskForm.tsx rename to app/src/tasks/TaskForm.tsx index c504e5e2..fd3baff1 100644 --- a/app/src/TaskForm.tsx +++ b/app/src/tasks/TaskForm.tsx @@ -17,14 +17,14 @@ import FormLabel from "react-bootstrap/FormLabel"; import FormSelect from "react-bootstrap/FormSelect"; import React, { ChangeEvent, ChangeEventHandler, Suspense } from "react"; import Row from "react-bootstrap/Row"; -import { ApiClientContext } from "./ApiClientContext"; +import { ApiClientContext } from "../ApiClientContext"; import { LinkContainer } from "react-router-bootstrap"; import ApiClient, { Account, Aggregator, NewTask, formikErrors, -} from "./ApiClient"; +} from "../ApiClient"; import { Formik, FormikHelpers, FormikProps } from "formik"; import FormCheck from "react-bootstrap/FormCheck"; import { DateTime } from "luxon"; @@ -204,7 +204,7 @@ function QueryType(props: FormikProps) { : /*jbr: I have no idea what a good * default is, but it needs to be * greater than min*/ - null + null ); }, [setFieldValue, min_batch_size] @@ -590,13 +590,13 @@ function Expiration(props: FormikProps) { const formValue = expiration ? DateTime.fromISO(expiration) - .toLocal() - .set({ second: 0, millisecond: 0 }) - .toISO({ - includeOffset: false, - suppressSeconds: true, - suppressMilliseconds: true, - }) || "" + .toLocal() + .set({ second: 0, millisecond: 0 }) + .toISO({ + includeOffset: false, + suppressSeconds: true, + suppressMilliseconds: true, + }) || "" : ""; return ( diff --git a/app/src/TaskList.tsx b/app/src/tasks/TaskList.tsx similarity index 89% rename from app/src/TaskList.tsx rename to app/src/tasks/TaskList.tsx index cfdec7fe..f0bb9ce1 100644 --- a/app/src/TaskList.tsx +++ b/app/src/tasks/TaskList.tsx @@ -4,12 +4,13 @@ import Row from "react-bootstrap/Row"; import ListGroup from "react-bootstrap/ListGroup"; import { Await, useLoaderData, useAsyncValue } from "react-router-dom"; import { Suspense } from "react"; -import { Task } from "./ApiClient"; +import { Task } from "../ApiClient"; import { Alert, Button, Spinner } from "react-bootstrap"; import { LinkContainer } from "react-router-bootstrap"; import { FileEarmarkCode } from "react-bootstrap-icons"; import { VdafIcon } from "./TaskDetail"; -import { AccountBreadcrumbs, WithAccount } from "./util"; +import { AccountBreadcrumbs, WithAccount } from "../util"; +import Placeholder from "react-bootstrap/Placeholder"; function Breadcrumbs() { return ( @@ -30,7 +31,7 @@ export default function AccountDetailFull() {

{" "} - + }> {(account) => account.name} {" "} Tasks diff --git a/app/src/tasks/index.tsx b/app/src/tasks/index.tsx new file mode 100644 index 00000000..4b48bf78 --- /dev/null +++ b/app/src/tasks/index.tsx @@ -0,0 +1,65 @@ +import TaskList from "./TaskList"; +import TaskForm from "./TaskForm"; +import TaskDetail from "./TaskDetail"; +import ApiClient from "../ApiClient"; +import { RouteObject, defer } from "react-router-dom"; + +export default function tasks(apiClient: ApiClient): RouteObject { + return { + path: "tasks", + children: [ + { + path: "", + index: true, + element: , + loader({ params }) { + return defer({ + tasks: apiClient.accountTasks(params.account_id as string), + }); + }, + }, + { + path: ":task_id", + element: , + loader({ params }) { + let task = apiClient.task(params.task_id as string); + let leaderAggregator = task.then((t) => + apiClient.aggregator(t.leader_aggregator_id) + ); + let helperAggregator = task.then((t) => + apiClient.aggregator(t.helper_aggregator_id) + ); + return defer({ + task, + leaderAggregator, + helperAggregator, + }); + }, + + async action({ params, request }) { + let data = Object.fromEntries(await request.formData()); + switch (request.method) { + case "PATCH": + return await apiClient.updateTask( + params.task_id as string, + data as { name: string } + ); + default: + throw new Error(`unexpected method ${request.method}`); + } + }, + }, + { + path: "new", + element: , + loader({ params }) { + return defer({ + aggregators: apiClient.accountAggregators( + params.account_id as string + ), + }); + }, + }, + ], + }; +} diff --git a/app/src/util.tsx b/app/src/util.tsx index c187c41f..584d3022 100644 --- a/app/src/util.tsx +++ b/app/src/util.tsx @@ -5,6 +5,7 @@ import { LinkContainer } from "react-router-bootstrap"; import { Suspense } from "react"; import { Await, useRouteLoaderData } from "react-router-dom"; import { Account } from "./ApiClient"; +import Placeholder from "react-bootstrap/Placeholder"; export function WithAccount({ children, @@ -33,7 +34,13 @@ export function AccountBreadcrumbs({ Accounts - + + + + } + > {(account) => ( diff --git a/migration/Cargo.toml b/migration/Cargo.toml index 9c69d0e0..056f161e 100644 --- a/migration/Cargo.toml +++ b/migration/Cargo.toml @@ -12,7 +12,9 @@ path = "src/lib.rs" [dependencies] async-std = { version = "1", features = ["attributes", "tokio1"] } clap = { version = "4.3.10", features = ["env", "derive"] } +sea-orm = "0.11.3" thiserror = "1.0.40" +time = "0.3.22" tracing = "0.1.37" tracing-subscriber = "0.3.17" diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 773166f9..3d714e99 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -13,6 +13,7 @@ mod m20230620_195535_add_aggregators_to_tasks; mod m20230622_232534_make_aggregator_api_url_mandatory; mod m20230626_183248_add_is_first_party_to_aggregators; mod m20230630_175314_create_api_tokens; +mod m20230703_201332_add_additional_fields_to_api_tokens; pub struct Migrator; @@ -33,6 +34,7 @@ impl MigratorTrait for Migrator { Box::new(m20230622_232534_make_aggregator_api_url_mandatory::Migration), Box::new(m20230626_183248_add_is_first_party_to_aggregators::Migration), Box::new(m20230630_175314_create_api_tokens::Migration), + Box::new(m20230703_201332_add_additional_fields_to_api_tokens::Migration), ] } } diff --git a/migration/src/m20230703_201332_add_additional_fields_to_api_tokens.rs b/migration/src/m20230703_201332_add_additional_fields_to_api_tokens.rs new file mode 100644 index 00000000..d4996867 --- /dev/null +++ b/migration/src/m20230703_201332_add_additional_fields_to_api_tokens.rs @@ -0,0 +1,71 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + TableAlterStatement::new() + .table(ApiToken::Table) + .add_column(ColumnDef::new(ApiToken::Name).string().null()) + .add_column( + ColumnDef::new(ApiToken::LastUsedAt) + .timestamp_with_time_zone() + .null(), + ) + .add_column( + ColumnDef::new(ApiToken::UpdatedAt) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await?; + + manager + .exec_stmt( + Query::update() + .table(ApiToken::Table) + .value(ApiToken::UpdatedAt, time::OffsetDateTime::now_utc()) + .to_owned(), + ) + .await?; + + manager + .alter_table( + TableAlterStatement::new() + .table(ApiToken::Table) + .modify_column( + ColumnDef::new(ApiToken::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + TableAlterStatement::new() + .table(ApiToken::Table) + .drop_column(ApiToken::Name) + .drop_column(ApiToken::LastUsedAt) + .drop_column(ApiToken::UpdatedAt) + .to_owned(), + ) + .await + } +} + +#[derive(Iden)] +enum ApiToken { + Table, + Name, + LastUsedAt, + UpdatedAt, +} diff --git a/src/entity.rs b/src/entity.rs index 43aa0864..75fa15a0 100644 --- a/src/entity.rs +++ b/src/entity.rs @@ -28,7 +28,9 @@ pub use aggregator::{ UpdateAggregator, }; -pub use api_token::{Column as ApiTokenColumn, Entity as ApiTokens, Model as ApiToken}; +pub use api_token::{ + Column as ApiTokenColumn, Entity as ApiTokens, Model as ApiToken, UpdateApiToken, +}; mod validators { const BASE64_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; diff --git a/src/entity/api_token.rs b/src/entity/api_token.rs index 1c317365..a0ec50bc 100644 --- a/src/entity/api_token.rs +++ b/src/entity/api_token.rs @@ -1,11 +1,10 @@ -use std::fmt::Debug; - -use super::{account, Account, AccountColumn, Accounts, Memberships}; +use super::{Account, AccountColumn, AccountRelation, Accounts, Memberships}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use rand::Rng; use sea_orm::{entity::prelude::*, IntoActiveModel}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use std::fmt::Debug; use time::OffsetDateTime; #[derive(Clone, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] @@ -20,7 +19,11 @@ pub struct Model { pub created_at: OffsetDateTime, #[serde(default, with = "::time::serde::iso8601::option")] pub deleted_at: Option, - + #[serde(default, with = "::time::serde::iso8601::option")] + pub last_used_at: Option, + #[serde(with = "::time::serde::iso8601")] + pub updated_at: OffsetDateTime, + pub name: Option, #[sea_orm(ignore)] #[serde(skip_serializing_if = "Option::is_none")] pub token: Option, @@ -62,11 +65,14 @@ mod url_safe_base64 { impl Debug for Model { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ApiToken") + .field("name", &self.name) .field("id", &self.id) .field("account_id", &self.account_id) .field("token_hash", &URL_SAFE_NO_PAD.encode(&self.token_hash)) .field("created_at", &self.created_at) .field("deleted_at", &self.deleted_at) + .field("last_used_at", &self.last_used_at) + .field("updated_at", &self.deleted_at) .finish() } } @@ -89,11 +95,11 @@ impl Related for Entity { impl Related for Entity { fn to() -> RelationDef { - account::Relation::Memberships.def() + AccountRelation::Memberships.def() } fn via() -> Option { - Some(account::Relation::ApiTokens.def().rev()) + Some(AccountRelation::ApiTokens.def().rev()) } } @@ -101,7 +107,7 @@ impl ActiveModelBehavior for ActiveModel {} impl Model { pub fn build(account: &Account) -> (ActiveModel, String) { - let mut token = [0u8; 16]; + let mut token = [0u8; 32]; rand::thread_rng().fill(&mut token); let token_hash = Sha256::digest(token).to_vec(); ( @@ -110,8 +116,11 @@ impl Model { account_id: account.id, token_hash, created_at: OffsetDateTime::now_utc(), + updated_at: OffsetDateTime::now_utc(), deleted_at: None, token: None, + last_used_at: None, + name: None, } .into_active_model(), URL_SAFE_NO_PAD.encode(token), @@ -121,14 +130,28 @@ impl Model { pub fn tombstone(self) -> ActiveModel { let mut api_token = self.into_active_model(); api_token.deleted_at = sea_orm::ActiveValue::Set(Some(OffsetDateTime::now_utc())); + api_token.updated_at = sea_orm::ActiveValue::Set(OffsetDateTime::now_utc()); api_token } pub fn is_tombstoned(&self) -> bool { self.deleted_at.is_some() } +} - pub fn updated_at(&self) -> OffsetDateTime { - self.deleted_at.unwrap_or(self.created_at) +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +pub struct UpdateApiToken { + name: Option, +} +impl UpdateApiToken { + pub fn build(self, model: Model) -> Result { + let mut api_token = model.into_active_model(); + api_token.name = sea_orm::ActiveValue::Set(match self.name { + Some(token) if token.is_empty() => None, + token => token, + }); + + api_token.updated_at = sea_orm::ActiveValue::Set(OffsetDateTime::now_utc()); + Ok(api_token) } } diff --git a/src/routes.rs b/src/routes.rs index a8c66716..7035a629 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -77,6 +77,7 @@ fn api_routes(config: &ApiConfig) -> impl Handler { (api(admin_required), api(aggregators::admin_create)), ) .delete("/api_tokens/:api_token_id", api(api_tokens::delete)) + .patch("/api_tokens/:api_token_id", api(api_tokens::update)) .any( &[Patch, Get, Post], "/accounts/:account_id/*", diff --git a/src/routes/api_tokens.rs b/src/routes/api_tokens.rs index e7858860..b59b996d 100644 --- a/src/routes/api_tokens.rs +++ b/src/routes/api_tokens.rs @@ -1,21 +1,26 @@ use crate::{ - entity::{Account, ApiToken, ApiTokens, MembershipColumn, Memberships}, + entity::{ + Account, ApiToken, ApiTokenColumn, ApiTokens, MembershipColumn, Memberships, UpdateApiToken, + }, handler::Error, user::User, Db, }; use sea_orm::{prelude::*, ActiveModelTrait, ModelTrait}; - use trillium::{Conn, Handler, Status}; use trillium_api::{FromConn, Json}; use trillium_caching_headers::CachingHeadersExt; use trillium_router::RouterConnExt; pub async fn index(conn: &mut Conn, (account, db): (Account, Db)) -> Result { - let api_tokens = account.find_related(ApiTokens).all(&db).await?; + let api_tokens = account + .find_related(ApiTokens) + .filter(ApiTokenColumn::DeletedAt.is_null()) + .all(&db) + .await?; if let Some(last_modified) = api_tokens .iter() - .map(|api_token| api_token.updated_at()) + .map(|api_token| api_token.updated_at) .max() { conn.set_last_modified(last_modified.into()); @@ -61,3 +66,11 @@ pub async fn delete(_: &mut Conn, (api_token, db): (ApiToken, Db)) -> Result), +) -> Result { + let token = update.build(api_token)?.update(&db).await?; + Ok((Json(token), Status::Ok)) +} diff --git a/tests/api_tokens.rs b/tests/api_tokens.rs index 2debeb87..fb322ef8 100644 --- a/tests/api_tokens.rs +++ b/tests/api_tokens.rs @@ -19,7 +19,7 @@ mod index { let (token1, _) = fixtures::api_token(&app, &account).await; let (token2, _) = fixtures::api_token(&app, &account).await; let (deleted, _) = fixtures::api_token(&app, &account).await; - let deleted = deleted.tombstone().update(app.db()).await.unwrap(); + let _deleted = deleted.tombstone().update(app.db()).await.unwrap(); let mut conn = get(format!("/api/accounts/{}/api_tokens", account.id)) .with_api_headers() @@ -29,7 +29,7 @@ mod index { assert_ok!(conn); let api_tokens: Vec = conn.response_json().await; - assert_same_json_representation(&api_tokens, &vec![token1, token2, deleted]); + assert_same_json_representation(&api_tokens, &vec![token1, token2]); Ok(()) } @@ -260,3 +260,85 @@ mod delete { Ok(()) } } + +mod update { + use uuid::Uuid; + + use super::{assert_eq, test, *}; + + #[test(harness = set_up)] + async fn nonexistant_api_token(app: DivviupApi) -> TestResult { + let (user, ..) = fixtures::member(&app).await; + let mut conn = patch(format!("/api/api_tokens/{}", Uuid::new_v4())) + .with_request_json(json!({"name": fixtures::random_name()})) + .with_api_headers() + .with_state(user) + .run_async(&app) + .await; + assert_not_found!(conn); + Ok(()) + } + + #[test(harness = set_up)] + async fn as_member(app: DivviupApi) -> TestResult { + let (user, account, ..) = fixtures::member(&app).await; + let (api_token, _) = fixtures::api_token(&app, &account).await; + let name = fixtures::random_name(); + let mut conn = patch(format!("/api/api_tokens/{}", api_token.id)) + .with_api_headers() + .with_request_json(json!({"name": name})) + .with_state(user) + .run_async(&app) + .await; + assert_status!(conn, 200); + let response: ApiToken = conn.response_json().await; + assert_eq!(response.name.unwrap(), name); + assert_eq!( + api_token.reload(app.db()).await?.unwrap().name.unwrap(), + name + ); + + Ok(()) + } + + #[test(harness = set_up)] + async fn non_member(app: DivviupApi) -> TestResult { + let account = fixtures::account(&app).await; + let (user, ..) = fixtures::member(&app).await; + let (api_token, ..) = fixtures::api_token(&app, &account).await; + let mut conn = patch(format!("/api/api_tokens/{}", api_token.id)) + .with_api_headers() + .with_request_json(json!({"name": fixtures::random_name()})) + .with_state(user) + .run_async(&app) + .await; + let name_before = api_token.name.clone(); + assert_not_found!(conn); + assert_eq!(api_token.reload(app.db()).await?.unwrap().name, name_before); + + Ok(()) + } + + #[test(harness = set_up)] + async fn admin_not_member(app: DivviupApi) -> TestResult { + let (admin, ..) = fixtures::admin(&app).await; + let account = fixtures::account(&app).await; + let (api_token, _) = fixtures::api_token(&app, &account).await; + let name = fixtures::random_name(); + let mut conn = patch(format!("/api/api_tokens/{}", api_token.id)) + .with_api_headers() + .with_request_json(json!({"name": name})) + .with_state(admin) + .run_async(&app) + .await; + assert_status!(conn, 200); + let response: ApiToken = conn.response_json().await; + assert_eq!(response.name.unwrap(), name); + assert_eq!( + api_token.reload(app.db()).await?.unwrap().name.unwrap(), + name + ); + + Ok(()) + } +} From 5134478d1b10c42127649eb2f5eeb9a0c7760681 Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Fri, 7 Jul 2023 10:45:39 -0700 Subject: [PATCH 2/3] fmt on nightly --- src/entity/task/new_task.rs | 12 +++++++++--- src/handler/error.rs | 4 +++- src/handler/oauth2.rs | 15 ++++++++++++--- tests/jobs.rs | 4 +++- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/entity/task/new_task.rs b/src/entity/task/new_task.rs index 453cb81b..4ec80650 100644 --- a/src/entity/task/new_task.rs +++ b/src/entity/task/new_task.rs @@ -81,8 +81,12 @@ async fn load_aggregator( id: Option<&str>, db: &impl ConnectionTrait, ) -> Result, Error> { - let Some(id) = id.map(Uuid::parse_str).transpose()? else { return Ok(None) }; - let Some(aggregator) = Aggregators::find_by_id(id).one(db).await? else { return Ok(None) }; + let Some(id) = id.map(Uuid::parse_str).transpose()? else { + return Ok(None); + }; + let Some(aggregator) = Aggregators::find_by_id(id).one(db).await? else { + return Ok(None); + }; if aggregator.account_id.is_none() || aggregator.account_id == Some(account.id) { Ok(Some(aggregator)) @@ -144,7 +148,9 @@ impl NewTask { errors.add("helper_aggregator_id", ValidationError::new("required")); } - let (Some(leader), Some(helper)) = (leader, helper) else { return None }; + let (Some(leader), Some(helper)) = (leader, helper) else { + return None; + }; if leader == helper { errors.add("leader_aggregator_id", ValidationError::new("same")); diff --git a/src/handler/error.rs b/src/handler/error.rs index d1cabdf5..e4902cb7 100644 --- a/src/handler/error.rs +++ b/src/handler/error.rs @@ -18,7 +18,9 @@ impl Handler for ErrorHandler { .take_state::() .map(Error::from) .or_else(|| conn.take_state()) - else { return conn }; + else { + return conn; + }; match error { Error::AccessDenied => conn.with_status(Status::Forbidden).with_body(""), diff --git a/src/handler/oauth2.rs b/src/handler/oauth2.rs index d0348c27..df2f67ce 100644 --- a/src/handler/oauth2.rs +++ b/src/handler/oauth2.rs @@ -48,12 +48,21 @@ pub async fn redirect(conn: Conn) -> Conn { pub async fn callback(conn: Conn) -> Conn { let qs = QueryStrong::parse(conn.querystring()).unwrap_or_default(); - let Some(auth_code) = qs.get_str("code").map(|c| AuthorizationCode::new(String::from(c))) else { - return conn.with_body("expected code query param").with_status(Status::Forbidden).halt() + let Some(auth_code) = qs + .get_str("code") + .map(|c| AuthorizationCode::new(String::from(c))) + else { + return conn + .with_body("expected code query param") + .with_status(Status::Forbidden) + .halt(); }; let Some(pkce_verifier) = conn.session().get("pkce_verifier") else { - return conn.with_body("expected pkce verifier in session").with_status(Status::Forbidden).halt() + return conn + .with_body("expected pkce verifier in session") + .with_status(Status::Forbidden) + .halt(); }; let session_csrf: Option = conn.session().get("csrf_token"); diff --git a/tests/jobs.rs b/tests/jobs.rs index e3200ca1..39b2e219 100644 --- a/tests/jobs.rs +++ b/tests/jobs.rs @@ -56,7 +56,9 @@ async fn reset_password(app: DivviupApi, client_logs: ClientLogs) -> TestResult .parse() .unwrap(); - let Job::V1(V1::SendInvitationEmail(next)) = next.job else { panic!() }; + let Job::V1(V1::SendInvitationEmail(next)) = next.job else { + panic!() + }; assert_eq!(next.membership_id, membership_id); assert_eq!(next.action_url, action_url); Ok(()) From 2622ab3d09d7b528f71c14d098a9533993d7d6ba Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Fri, 7 Jul 2023 10:46:45 -0700 Subject: [PATCH 3/3] manual json macro format --- tests/api_tokens.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/api_tokens.rs b/tests/api_tokens.rs index fb322ef8..a6577f8a 100644 --- a/tests/api_tokens.rs +++ b/tests/api_tokens.rs @@ -270,7 +270,7 @@ mod update { async fn nonexistant_api_token(app: DivviupApi) -> TestResult { let (user, ..) = fixtures::member(&app).await; let mut conn = patch(format!("/api/api_tokens/{}", Uuid::new_v4())) - .with_request_json(json!({"name": fixtures::random_name()})) + .with_request_json(json!({ "name": fixtures::random_name() })) .with_api_headers() .with_state(user) .run_async(&app) @@ -286,7 +286,7 @@ mod update { let name = fixtures::random_name(); let mut conn = patch(format!("/api/api_tokens/{}", api_token.id)) .with_api_headers() - .with_request_json(json!({"name": name})) + .with_request_json(json!({ "name": name })) .with_state(user) .run_async(&app) .await; @@ -308,7 +308,7 @@ mod update { let (api_token, ..) = fixtures::api_token(&app, &account).await; let mut conn = patch(format!("/api/api_tokens/{}", api_token.id)) .with_api_headers() - .with_request_json(json!({"name": fixtures::random_name()})) + .with_request_json(json!({ "name": fixtures::random_name() })) .with_state(user) .run_async(&app) .await; @@ -327,7 +327,7 @@ mod update { let name = fixtures::random_name(); let mut conn = patch(format!("/api/api_tokens/{}", api_token.id)) .with_api_headers() - .with_request_json(json!({"name": name})) + .with_request_json(json!({ "name": name })) .with_state(admin) .run_async(&app) .await;