set up channels and email sending
This commit is contained in:
parent
2acb922979
commit
d051b97810
18 changed files with 1561 additions and 44 deletions
399
Cargo.lock
generated
399
Cargo.lock
generated
|
@ -305,6 +305,7 @@ dependencies = [
|
||||||
"axum-core 0.5.0",
|
"axum-core 0.5.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie",
|
||||||
|
"form_urlencoded",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"headers",
|
"headers",
|
||||||
"http 1.1.0",
|
"http 1.1.0",
|
||||||
|
@ -313,6 +314,8 @@ dependencies = [
|
||||||
"mime",
|
"mime",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_html_form",
|
||||||
|
"serde_path_to_error",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
@ -503,6 +506,16 @@ dependencies = [
|
||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chumsky"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
"stacker",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "config"
|
name = "config"
|
||||||
version = "0.14.1"
|
version = "0.14.1"
|
||||||
|
@ -789,6 +802,17 @@ dependencies = [
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "displaydoc"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dlv-list"
|
name = "dlv-list"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
|
@ -824,6 +848,22 @@ version = "1.13.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
|
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email-encoding"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ea3d894bbbab314476b265f9b2d46bf24b123a36dd0e96b06a1b49545b9d9dcc"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email_address"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.34"
|
version = "0.8.34"
|
||||||
|
@ -1128,6 +1168,17 @@ dependencies = [
|
||||||
"digest 0.9.0",
|
"digest 0.9.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hostname"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if 1.0.0",
|
||||||
|
"libc",
|
||||||
|
"windows",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "0.2.12"
|
version = "0.2.12"
|
||||||
|
@ -1345,6 +1396,124 @@ dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_collections"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_locid"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"litemap",
|
||||||
|
"tinystr",
|
||||||
|
"writeable",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_locid_transform"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"icu_locid",
|
||||||
|
"icu_locid_transform_data",
|
||||||
|
"icu_provider",
|
||||||
|
"tinystr",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_locid_transform_data"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_normalizer"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"icu_collections",
|
||||||
|
"icu_normalizer_data",
|
||||||
|
"icu_properties",
|
||||||
|
"icu_provider",
|
||||||
|
"smallvec",
|
||||||
|
"utf16_iter",
|
||||||
|
"utf8_iter",
|
||||||
|
"write16",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_normalizer_data"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_properties"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"icu_collections",
|
||||||
|
"icu_locid_transform",
|
||||||
|
"icu_properties_data",
|
||||||
|
"icu_provider",
|
||||||
|
"tinystr",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_properties_data"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_provider"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"icu_locid",
|
||||||
|
"icu_provider_macros",
|
||||||
|
"stable_deref_trait",
|
||||||
|
"tinystr",
|
||||||
|
"writeable",
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_provider_macros"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ident_case"
|
name = "ident_case"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
|
@ -1361,6 +1530,27 @@ dependencies = [
|
||||||
"unicode-normalization",
|
"unicode-normalization",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e"
|
||||||
|
dependencies = [
|
||||||
|
"idna_adapter",
|
||||||
|
"smallvec",
|
||||||
|
"utf8_iter",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna_adapter"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71"
|
||||||
|
dependencies = [
|
||||||
|
"icu_normalizer",
|
||||||
|
"icu_properties",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.6.0"
|
version = "2.6.0"
|
||||||
|
@ -1409,6 +1599,36 @@ version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lettre"
|
||||||
|
version = "0.11.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e882e1489810a45919477602194312b1a7df0e5acc30a6188be7b520268f63f8"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"chumsky",
|
||||||
|
"email-encoding",
|
||||||
|
"email_address",
|
||||||
|
"fastrand",
|
||||||
|
"futures-io",
|
||||||
|
"futures-util",
|
||||||
|
"hostname",
|
||||||
|
"httpdate",
|
||||||
|
"idna 1.0.3",
|
||||||
|
"mime",
|
||||||
|
"native-tls",
|
||||||
|
"nom",
|
||||||
|
"percent-encoding",
|
||||||
|
"quoted_printable",
|
||||||
|
"serde",
|
||||||
|
"socket2",
|
||||||
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.161"
|
version = "0.2.161"
|
||||||
|
@ -1427,6 +1647,12 @@ version = "0.4.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
|
checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litemap"
|
||||||
|
version = "0.7.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.22"
|
version = "0.4.22"
|
||||||
|
@ -1768,6 +1994,15 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psm"
|
||||||
|
version = "0.1.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.37"
|
version = "1.0.37"
|
||||||
|
@ -1777,6 +2012,12 @@ dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quoted_printable"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.8.5"
|
version = "0.8.5"
|
||||||
|
@ -2135,6 +2376,19 @@ dependencies = [
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_html_form"
|
||||||
|
version = "0.2.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"indexmap",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.132"
|
version = "1.0.132"
|
||||||
|
@ -2246,8 +2500,10 @@ dependencies = [
|
||||||
"diesel",
|
"diesel",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures",
|
"futures",
|
||||||
|
"lettre",
|
||||||
"oauth2",
|
"oauth2",
|
||||||
"rand",
|
"rand",
|
||||||
|
"regex",
|
||||||
"reqwest 0.12.8",
|
"reqwest 0.12.8",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
@ -2291,6 +2547,25 @@ version = "0.9.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "stable_deref_trait"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "stacker"
|
||||||
|
version = "0.1.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d08feb8f695b465baed819b03c128dc23f57a694510ab1f06c77f763975685e"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if 1.0.0",
|
||||||
|
"libc",
|
||||||
|
"psm",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
|
@ -2329,6 +2604,17 @@ dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "synstructure"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-configuration"
|
name = "system-configuration"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
@ -2454,6 +2740,16 @@ dependencies = [
|
||||||
"crunchy",
|
"crunchy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinystr"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinyvec"
|
name = "tinyvec"
|
||||||
version = "1.8.0"
|
version = "1.8.0"
|
||||||
|
@ -2757,11 +3053,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c"
|
checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"form_urlencoded",
|
"form_urlencoded",
|
||||||
"idna",
|
"idna 0.5.0",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf16_iter"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8_iter"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.11.0"
|
version = "1.11.0"
|
||||||
|
@ -2911,6 +3219,16 @@ version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
|
@ -3117,6 +3435,18 @@ dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "write16"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "writeable"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yaml-rust2"
|
name = "yaml-rust2"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
|
@ -3128,6 +3458,30 @@ dependencies = [
|
||||||
"hashlink",
|
"hashlink",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yoke"
|
||||||
|
version = "0.7.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"stable_deref_trait",
|
||||||
|
"yoke-derive",
|
||||||
|
"zerofrom",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yoke-derive"
|
||||||
|
version = "0.7.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"synstructure",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.7.35"
|
version = "0.7.35"
|
||||||
|
@ -3149,8 +3503,51 @@ dependencies = [
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerofrom"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e"
|
||||||
|
dependencies = [
|
||||||
|
"zerofrom-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerofrom-derive"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"synstructure",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zeroize"
|
name = "zeroize"
|
||||||
version = "1.8.1"
|
version = "1.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
|
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerovec"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079"
|
||||||
|
dependencies = [
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
"zerovec-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerovec-derive"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
|
@ -25,8 +25,10 @@ tower-http = { version = "0.6.2", features = ["compression-br", "compression-gzi
|
||||||
tokio = { version = "1.42.0", features = ["macros", "rt-multi-thread", "tracing"] }
|
tokio = { version = "1.42.0", features = ["macros", "rt-multi-thread", "tracing"] }
|
||||||
deadpool-diesel = { version = "0.6.1", features = ["postgres", "serde"] }
|
deadpool-diesel = { version = "0.6.1", features = ["postgres", "serde"] }
|
||||||
axum = { version = "0.8.1", features = ["macros"] }
|
axum = { version = "0.8.1", features = ["macros"] }
|
||||||
axum-extra = { version = "0.10.0", features = ["cookie", "typed-header"] }
|
axum-extra = { version = "0.10.0", features = ["cookie", "form", "typed-header"] }
|
||||||
chrono = { version = "0.4.39", features = ["serde"] }
|
chrono = { version = "0.4.39", features = ["serde"] }
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
diesel = { version = "2.2.6", features = ["postgres", "chrono", "uuid"] }
|
diesel = { version = "2.2.6", features = ["postgres", "chrono", "uuid"] }
|
||||||
tower = "0.5.2"
|
tower = "0.5.2"
|
||||||
|
regex = "1.11.1"
|
||||||
|
lettre = { version = "0.11.12", features = ["tokio1", "serde", "tracing", "tokio1-native-tls"] }
|
||||||
|
|
4
migrations/2025-02-04-070208_init_channels/down.sql
Normal file
4
migrations/2025-02-04-070208_init_channels/down.sql
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
DROP TABLE IF EXISTS channel_selections;
|
||||||
|
DROP TABLE IF EXISTS slack_channels;
|
||||||
|
DROP TABLE IF EXISTS email_channels;
|
||||||
|
DROP TABLE IF EXISTS channels;
|
29
migrations/2025-02-04-070208_init_channels/up.sql
Normal file
29
migrations/2025-02-04-070208_init_channels/up.sql
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS channels (
|
||||||
|
id UUID NOT NULL PRIMARY KEY,
|
||||||
|
team_id UUID NOT NULL REFERENCES teams(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
enable_by_default BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS email_channels (
|
||||||
|
id UUID NOT NULL PRIMARY KEY REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
|
recipient TEXT NOT NULL DEFAULT '',
|
||||||
|
verification_code TEXT NOT NULL DEFAULT '',
|
||||||
|
verification_code_guesses INT NOT NULL DEFAULT 0,
|
||||||
|
verified BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS slack_channels (
|
||||||
|
id UUID NOT NULL PRIMARY KEY REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
|
oauth_state TEXT NOT NULL DEFAULT '',
|
||||||
|
access_token TEXT NOT NULL DEFAULT '',
|
||||||
|
conversation_id TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_selections (
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id),
|
||||||
|
channel_id UUID NOT NULL REFERENCES channels(id),
|
||||||
|
PRIMARY KEY (project_id, channel_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX ON channel_selections (project_id);
|
||||||
|
CREATE INDEX ON channel_selections (channel_id);
|
|
@ -1,4 +1,5 @@
|
||||||
use anyhow::Error;
|
use std::fmt::{self, Display};
|
||||||
|
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
|
|
||||||
|
@ -6,8 +7,10 @@ use axum::response::{IntoResponse, Response};
|
||||||
// For a simplified example of using anyhow in axum check /examples/anyhow-error-response
|
// For a simplified example of using anyhow in axum check /examples/anyhow-error-response
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
InternalServerError(Error),
|
InternalServerError(anyhow::Error),
|
||||||
ForbiddenError(String),
|
ForbiddenError(String),
|
||||||
|
NotFoundError(String),
|
||||||
|
BadRequestError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tell axum how to convert `AppError` into a response.
|
// Tell axum how to convert `AppError` into a response.
|
||||||
|
@ -22,6 +25,14 @@ impl IntoResponse for AppError {
|
||||||
tracing::info!("Forbidden: {}", client_message);
|
tracing::info!("Forbidden: {}", client_message);
|
||||||
(StatusCode::FORBIDDEN, client_message).into_response()
|
(StatusCode::FORBIDDEN, client_message).into_response()
|
||||||
}
|
}
|
||||||
|
Self::NotFoundError(client_message) => {
|
||||||
|
tracing::info!("Not found: {}", client_message);
|
||||||
|
(StatusCode::NOT_FOUND, client_message).into_response()
|
||||||
|
}
|
||||||
|
Self::BadRequestError(client_message) => {
|
||||||
|
tracing::info!("Bad user input: {}", client_message);
|
||||||
|
(StatusCode::BAD_REQUEST, client_message).into_response()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -36,3 +47,20 @@ where
|
||||||
Self::InternalServerError(Into::<anyhow::Error>::into(err))
|
Self::InternalServerError(Into::<anyhow::Error>::into(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Display for AppError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
AppError::InternalServerError(inner) => inner.fmt(f),
|
||||||
|
AppError::ForbiddenError(client_message) => {
|
||||||
|
write!(f, "ForbiddenError: {}", client_message)
|
||||||
|
}
|
||||||
|
AppError::NotFoundError(client_message) => {
|
||||||
|
write!(f, "NotFoundError: {}", client_message)
|
||||||
|
}
|
||||||
|
AppError::BadRequestError(client_message) => {
|
||||||
|
write!(f, "BadRequestError: {}", client_message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -3,18 +3,29 @@ use axum::{
|
||||||
http::request::Parts,
|
http::request::Parts,
|
||||||
};
|
};
|
||||||
use deadpool_diesel::postgres::{Connection, Pool};
|
use deadpool_diesel::postgres::{Connection, Pool};
|
||||||
|
use lettre::SmtpTransport;
|
||||||
use oauth2::basic::BasicClient;
|
use oauth2::basic::BasicClient;
|
||||||
|
|
||||||
use crate::{app_error::AppError, sessions::PgStore, settings::Settings};
|
use crate::{app_error::AppError, sessions::PgStore, settings::Settings};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct AppState {
|
pub struct AppState {
|
||||||
pub db_pool: Pool,
|
pub db_pool: Pool,
|
||||||
|
pub mailer: Mailer,
|
||||||
pub oauth_client: BasicClient,
|
pub oauth_client: BasicClient,
|
||||||
pub session_store: PgStore,
|
pub session_store: PgStore,
|
||||||
pub settings: Settings,
|
pub settings: Settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Mailer(pub SmtpTransport);
|
||||||
|
|
||||||
|
impl FromRef<AppState> for Mailer {
|
||||||
|
fn from_ref(state: &AppState) -> Self {
|
||||||
|
state.mailer.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl FromRef<AppState> for PgStore {
|
impl FromRef<AppState> for PgStore {
|
||||||
fn from_ref(state: &AppState) -> Self {
|
fn from_ref(state: &AppState) -> Self {
|
||||||
state.session_store.clone()
|
state.session_store.clone()
|
||||||
|
|
36
src/channel_selections.rs
Normal file
36
src/channel_selections.rs
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
use diesel::{
|
||||||
|
dsl::{auto_type, AsSelect},
|
||||||
|
pg::Pg,
|
||||||
|
prelude::*,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::schema::channel_selections;
|
||||||
|
|
||||||
|
#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)]
|
||||||
|
#[diesel(belongs_to(crate::channels::Channel))]
|
||||||
|
#[diesel(belongs_to(crate::projects::Project))]
|
||||||
|
#[diesel(primary_key(channel_id, project_id))]
|
||||||
|
#[diesel(check_for_backend(Pg))]
|
||||||
|
pub struct ChannelSelection {
|
||||||
|
pub project_id: Uuid,
|
||||||
|
pub channel_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelSelection {
|
||||||
|
#[auto_type(no_type_alias)]
|
||||||
|
pub fn all() -> _ {
|
||||||
|
let select: AsSelect<ChannelSelection, Pg> = Self::as_select();
|
||||||
|
channel_selections::table.select(select)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[auto_type(no_type_alias)]
|
||||||
|
pub fn with_channel(channel_id: Uuid) -> _ {
|
||||||
|
channel_selections::channel_id.eq(channel_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[auto_type(no_type_alias)]
|
||||||
|
pub fn with_project(project_id: Uuid) -> _ {
|
||||||
|
channel_selections::project_id.eq(project_id)
|
||||||
|
}
|
||||||
|
}
|
100
src/channels.rs
Normal file
100
src/channels.rs
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
use anyhow::Context;
|
||||||
|
use deadpool_diesel::postgres::Connection;
|
||||||
|
use diesel::{
|
||||||
|
dsl::{auto_type, insert_into, AsSelect},
|
||||||
|
pg::Pg,
|
||||||
|
prelude::*,
|
||||||
|
Connection as _,
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app_error::AppError,
|
||||||
|
schema::{channels, email_channels, slack_channels},
|
||||||
|
teams::Team,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)]
|
||||||
|
#[diesel(belongs_to(Team))]
|
||||||
|
pub struct Channel {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub team_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub enable_by_default: bool,
|
||||||
|
|
||||||
|
#[diesel(embed)]
|
||||||
|
pub email_data: Option<EmailChannel>,
|
||||||
|
#[diesel(embed)]
|
||||||
|
pub slack_data: Option<SlackChannel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable, Serialize,
|
||||||
|
)]
|
||||||
|
#[diesel(belongs_to(Channel, foreign_key = id))]
|
||||||
|
pub struct EmailChannel {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub recipient: String,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub verification_code: String,
|
||||||
|
pub verification_code_guesses: i32,
|
||||||
|
pub verified: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
|
||||||
|
#[diesel(belongs_to(Channel, foreign_key = id))]
|
||||||
|
pub struct SlackChannel {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub oauth_state: String,
|
||||||
|
pub access_token: String,
|
||||||
|
pub conversation_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Channel {
|
||||||
|
#[auto_type(no_type_alias)]
|
||||||
|
pub fn all() -> _ {
|
||||||
|
let select: AsSelect<Channel, Pg> = Channel::as_select();
|
||||||
|
channels::table
|
||||||
|
.left_join(email_channels::table)
|
||||||
|
.left_join(slack_channels::table)
|
||||||
|
.select(select)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[auto_type(no_type_alias)]
|
||||||
|
pub fn with_id(channel_id: Uuid) -> _ {
|
||||||
|
channels::id.eq(channel_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[auto_type(no_type_alias)]
|
||||||
|
pub fn with_team(team_id: Uuid) -> _ {
|
||||||
|
channels::team_id.eq(team_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_email_channel(
|
||||||
|
db_conn: &Connection,
|
||||||
|
team_id: Uuid,
|
||||||
|
) -> Result<Self, AppError> {
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
db_conn
|
||||||
|
.interact(move |conn| {
|
||||||
|
conn.transaction(move |conn| {
|
||||||
|
insert_into(channels::table)
|
||||||
|
.values((
|
||||||
|
channels::id.eq(id.clone()),
|
||||||
|
channels::team_id.eq(team_id),
|
||||||
|
channels::name.eq("Untitled Email Channel"),
|
||||||
|
))
|
||||||
|
.execute(conn)?;
|
||||||
|
insert_into(email_channels::table)
|
||||||
|
.values(email_channels::id.eq(id.clone()))
|
||||||
|
.execute(conn)?;
|
||||||
|
Self::all().filter(Self::with_id(id)).first(conn)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.context("Failed to insert new EmailChannel.")
|
||||||
|
.map_err(|err| err.into())
|
||||||
|
}
|
||||||
|
}
|
21
src/main.rs
21
src/main.rs
|
@ -2,6 +2,8 @@ mod api_keys;
|
||||||
mod app_error;
|
mod app_error;
|
||||||
mod app_state;
|
mod app_state;
|
||||||
mod auth;
|
mod auth;
|
||||||
|
mod channel_selections;
|
||||||
|
mod channels;
|
||||||
mod csrf;
|
mod csrf;
|
||||||
mod guards;
|
mod guards;
|
||||||
mod messages;
|
mod messages;
|
||||||
|
@ -18,7 +20,12 @@ mod v0_router;
|
||||||
|
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use crate::{app_state::AppState, router::new_router, sessions::PgStore, settings::Settings};
|
use crate::{
|
||||||
|
app_state::{AppState, Mailer},
|
||||||
|
router::new_router,
|
||||||
|
sessions::PgStore,
|
||||||
|
settings::Settings,
|
||||||
|
};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
@ -37,9 +44,21 @@ async fn main() {
|
||||||
|
|
||||||
let session_store = PgStore::new(db_pool.clone());
|
let session_store = PgStore::new(db_pool.clone());
|
||||||
|
|
||||||
|
let mailer_creds = lettre::transport::smtp::authentication::Credentials::new(
|
||||||
|
settings.email.smtp_username.clone(),
|
||||||
|
settings.email.smtp_password.clone(),
|
||||||
|
);
|
||||||
|
let mailer = Mailer(
|
||||||
|
lettre::SmtpTransport::starttls_relay(&settings.email.smtp_server)
|
||||||
|
.unwrap()
|
||||||
|
.credentials(mailer_creds)
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
|
||||||
let oauth_client = auth::new_oauth_client(&settings).unwrap();
|
let oauth_client = auth::new_oauth_client(&settings).unwrap();
|
||||||
let app_state = AppState {
|
let app_state = AppState {
|
||||||
db_pool,
|
db_pool,
|
||||||
|
mailer,
|
||||||
oauth_client,
|
oauth_client,
|
||||||
session_store,
|
session_store,
|
||||||
settings: settings.clone(),
|
settings: settings.clone(),
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
use diesel::{
|
use diesel::{
|
||||||
dsl::{auto_type, AsSelect},
|
dsl::{auto_type, AsSelect, Eq},
|
||||||
pg::Pg,
|
pg::Pg,
|
||||||
prelude::*,
|
prelude::*,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{schema::projects, teams::Team};
|
use crate::{
|
||||||
|
channels::Channel,
|
||||||
|
schema::{channel_selections, channels, email_channels, projects, slack_channels},
|
||||||
|
teams::Team,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
|
#[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)]
|
||||||
#[diesel(table_name = projects)]
|
#[diesel(table_name = projects)]
|
||||||
|
@ -23,6 +27,11 @@ impl Project {
|
||||||
projects::table.select(select)
|
projects::table.select(select)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[auto_type(no_type_alias)]
|
||||||
|
pub fn with_id(project_id: Uuid) -> _ {
|
||||||
|
projects::id.eq(project_id)
|
||||||
|
}
|
||||||
|
|
||||||
#[auto_type(no_type_alias)]
|
#[auto_type(no_type_alias)]
|
||||||
pub fn with_team(team_id: Uuid) -> _ {
|
pub fn with_team(team_id: Uuid) -> _ {
|
||||||
projects::team_id.eq(team_id)
|
projects::team_id.eq(team_id)
|
||||||
|
@ -32,4 +41,17 @@ impl Project {
|
||||||
pub fn with_name(name: String) -> _ {
|
pub fn with_name(name: String) -> _ {
|
||||||
projects::name.eq(name)
|
projects::name.eq(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[auto_type(no_type_alias)]
|
||||||
|
pub fn selected_channels(&self) -> _ {
|
||||||
|
let select: AsSelect<Channel, Pg> = Channel::as_select();
|
||||||
|
let project_filter: Eq<channel_selections::project_id, Uuid> =
|
||||||
|
channel_selections::project_id.eq(self.id);
|
||||||
|
channels::table
|
||||||
|
.left_join(email_channels::table)
|
||||||
|
.left_join(slack_channels::table)
|
||||||
|
.inner_join(channel_selections::table)
|
||||||
|
.filter(project_filter)
|
||||||
|
.select(select)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
552
src/router.rs
552
src/router.rs
|
@ -1,12 +1,18 @@
|
||||||
use anyhow::Context;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context};
|
||||||
use askama_axum::Template;
|
use askama_axum::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
response::{Html, IntoResponse, Redirect},
|
response::{Html, IntoResponse, Redirect},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Form, Router,
|
Router,
|
||||||
};
|
};
|
||||||
use diesel::{dsl::insert_into, prelude::*};
|
use axum_extra::extract::Form;
|
||||||
|
use diesel::{delete, dsl::insert_into, prelude::*, update};
|
||||||
|
use lettre::Transport as _;
|
||||||
|
use rand::{distributions::Uniform, Rng};
|
||||||
|
use regex::Regex;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tower::ServiceBuilder;
|
use tower::ServiceBuilder;
|
||||||
use tower_http::{
|
use tower_http::{
|
||||||
|
@ -19,13 +25,15 @@ use uuid::Uuid;
|
||||||
use crate::{
|
use crate::{
|
||||||
api_keys::ApiKey,
|
api_keys::ApiKey,
|
||||||
app_error::AppError,
|
app_error::AppError,
|
||||||
app_state::{AppState, DbConn},
|
app_state::{AppState, DbConn, Mailer},
|
||||||
auth,
|
auth,
|
||||||
|
channel_selections::ChannelSelection,
|
||||||
|
channels::Channel,
|
||||||
csrf::generate_csrf_token,
|
csrf::generate_csrf_token,
|
||||||
guards,
|
guards,
|
||||||
nav_state::{Breadcrumb, NavState},
|
nav_state::{Breadcrumb, NavState},
|
||||||
projects::Project,
|
projects::Project,
|
||||||
schema,
|
schema::{self, channel_selections, channels, email_channels},
|
||||||
settings::Settings,
|
settings::Settings,
|
||||||
team_memberships::TeamMembership,
|
team_memberships::TeamMembership,
|
||||||
teams::Team,
|
teams::Team,
|
||||||
|
@ -36,14 +44,34 @@ use crate::{
|
||||||
pub fn new_router(state: AppState) -> Router<()> {
|
pub fn new_router(state: AppState) -> Router<()> {
|
||||||
let base_path = state.settings.base_path.clone();
|
let base_path = state.settings.base_path.clone();
|
||||||
Router::new().nest(
|
Router::new().nest(
|
||||||
format!("{}", base_path).as_str(),
|
base_path.as_str(),
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(landing_page))
|
.route("/", get(landing_page))
|
||||||
.merge(v0_router::new_router(state.clone()))
|
.merge(v0_router::new_router(state.clone()))
|
||||||
.route("/teams", get(teams_page))
|
.route("/teams", get(teams_page))
|
||||||
.route("/teams/{team_id}", get(team_page))
|
.route("/teams/{team_id}", get(team_page))
|
||||||
.route("/teams/{team_id}/projects", get(projects_page))
|
.route("/teams/{team_id}/projects", get(projects_page))
|
||||||
|
.route("/teams/{team_id}/projects/{project_id}", get(project_page))
|
||||||
|
.route(
|
||||||
|
"/teams/{team_id}/projects/{project_id}/update-enabled-channels",
|
||||||
|
post(update_enabled_channels),
|
||||||
|
)
|
||||||
.route("/teams/{team_id}/new-api-key", post(post_new_api_key))
|
.route("/teams/{team_id}/new-api-key", post(post_new_api_key))
|
||||||
|
.route("/teams/{team_id}/channels", get(channels_page))
|
||||||
|
.route("/teams/{team_id}/channels/{channel_id}", get(channel_page))
|
||||||
|
.route(
|
||||||
|
"/teams/{team_id}/channels/{channel_id}/update-channel",
|
||||||
|
post(update_channel),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/teams/{team_id}/channels/{channel_id}/update-email-recipient",
|
||||||
|
post(update_channel_email_recipient),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/teams/{team_id}/channels/{channel_id}/verify-email",
|
||||||
|
post(verify_email),
|
||||||
|
)
|
||||||
|
.route("/teams/{team_id}/new-channel", post(post_new_channel))
|
||||||
.route("/new-team", get(new_team_page))
|
.route("/new-team", get(new_team_page))
|
||||||
.route("/new-team", post(post_new_team))
|
.route("/new-team", post(post_new_team))
|
||||||
.nest("/auth", auth::new_router())
|
.nest("/auth", auth::new_router())
|
||||||
|
@ -250,3 +278,515 @@ async fn projects_page(
|
||||||
)
|
)
|
||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn channels_page(
|
||||||
|
State(Settings { base_path, .. }): State<Settings>,
|
||||||
|
DbConn(db_conn): DbConn,
|
||||||
|
Path(team_id): Path<Uuid>,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?;
|
||||||
|
|
||||||
|
let team_filter = Channel::with_team(team_id);
|
||||||
|
let channels = db_conn
|
||||||
|
.interact(move |conn| Channel::all().filter(team_filter).load(conn))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.context("Failed to load channels list.")?;
|
||||||
|
|
||||||
|
let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id.clone())).await?;
|
||||||
|
let nav_state = NavState::new()
|
||||||
|
.set_base_path(&base_path)
|
||||||
|
.push_team(&team)
|
||||||
|
.push_slug(Breadcrumb {
|
||||||
|
href: "channels".to_string(),
|
||||||
|
label: "Channels".to_string(),
|
||||||
|
})
|
||||||
|
.set_navbar_active_item("channels");
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "channels.html")]
|
||||||
|
struct ResponseTemplate {
|
||||||
|
base_path: String,
|
||||||
|
channels: Vec<Channel>,
|
||||||
|
csrf_token: String,
|
||||||
|
nav_state: NavState,
|
||||||
|
}
|
||||||
|
Ok(Html(
|
||||||
|
ResponseTemplate {
|
||||||
|
base_path,
|
||||||
|
channels,
|
||||||
|
csrf_token,
|
||||||
|
nav_state,
|
||||||
|
}
|
||||||
|
.render()?,
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct NewChannelPostFormBody {
|
||||||
|
csrf_token: String,
|
||||||
|
channel_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_new_channel(
|
||||||
|
State(Settings { base_path, .. }): State<Settings>,
|
||||||
|
DbConn(db_conn): DbConn,
|
||||||
|
Path(team_id): Path<Uuid>,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
Form(form_body): Form<NewChannelPostFormBody>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?;
|
||||||
|
guards::require_valid_csrf_token(&form_body.csrf_token, ¤t_user, &db_conn).await?;
|
||||||
|
|
||||||
|
let channel = match form_body.channel_type.as_str() {
|
||||||
|
"email" => Channel::create_email_channel(&db_conn, team.id.clone()).await?,
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::BadRequestError(
|
||||||
|
"Channel type not recognized.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Redirect::to(&format!(
|
||||||
|
"{}/teams/{}/channels/{}",
|
||||||
|
base_path,
|
||||||
|
team.id.simple(),
|
||||||
|
channel.id.simple()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn channel_page(
|
||||||
|
State(Settings { base_path, .. }): State<Settings>,
|
||||||
|
DbConn(db_conn): DbConn,
|
||||||
|
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?;
|
||||||
|
|
||||||
|
let id_filter = Channel::with_id(channel_id);
|
||||||
|
let team_filter = Channel::with_team(team_id.clone());
|
||||||
|
let channel = match db_conn
|
||||||
|
.interact(move |conn| {
|
||||||
|
Channel::all()
|
||||||
|
.filter(id_filter)
|
||||||
|
.filter(team_filter)
|
||||||
|
.first(conn)
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()?
|
||||||
|
{
|
||||||
|
None => {
|
||||||
|
return Err(AppError::NotFoundError(
|
||||||
|
"Channel with that team and ID not found".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(channel) => channel,
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id.clone())).await?;
|
||||||
|
let nav_state = NavState::new()
|
||||||
|
.set_base_path(&base_path)
|
||||||
|
.push_team(&team)
|
||||||
|
.push_slug(Breadcrumb {
|
||||||
|
href: "channels".to_string(),
|
||||||
|
label: "Channels".to_string(),
|
||||||
|
})
|
||||||
|
.push_slug(Breadcrumb {
|
||||||
|
href: channel.id.simple().to_string(),
|
||||||
|
label: channel.name.clone(),
|
||||||
|
})
|
||||||
|
.set_navbar_active_item("channels");
|
||||||
|
|
||||||
|
if channel.email_data.is_some() {
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "channel-email.html")]
|
||||||
|
struct ResponseTemplate {
|
||||||
|
base_path: String,
|
||||||
|
channel: Channel,
|
||||||
|
csrf_token: String,
|
||||||
|
nav_state: NavState,
|
||||||
|
}
|
||||||
|
Ok(Html(
|
||||||
|
ResponseTemplate {
|
||||||
|
base_path,
|
||||||
|
channel,
|
||||||
|
csrf_token,
|
||||||
|
nav_state,
|
||||||
|
}
|
||||||
|
.render()?,
|
||||||
|
))
|
||||||
|
} else if channel.slack_data.is_some() {
|
||||||
|
Err(anyhow::anyhow!("Slack channel config page is not yet implemented.").into())
|
||||||
|
} else {
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"Channel doesn't have a recognized variant for which to render a config page."
|
||||||
|
)
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UpdateChannelFormBody {
|
||||||
|
csrf_token: String,
|
||||||
|
name: String,
|
||||||
|
enable_by_default: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_channel(
|
||||||
|
State(Settings { base_path, .. }): State<Settings>,
|
||||||
|
DbConn(db_conn): DbConn,
|
||||||
|
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
Form(form_body): Form<UpdateChannelFormBody>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
guards::require_valid_csrf_token(&form_body.csrf_token, ¤t_user, &db_conn).await?;
|
||||||
|
guards::require_team_membership(¤t_user, &team_id, &db_conn).await?;
|
||||||
|
|
||||||
|
let id_filter = Channel::with_id(channel_id.clone());
|
||||||
|
let team_filter = Channel::with_team(team_id.clone());
|
||||||
|
let updated_rows = db_conn
|
||||||
|
.interact(move |conn| {
|
||||||
|
update(channels::table.filter(id_filter).filter(team_filter))
|
||||||
|
.set((
|
||||||
|
channels::name.eq(form_body.name),
|
||||||
|
channels::enable_by_default
|
||||||
|
.eq(form_body.enable_by_default.unwrap_or("false".to_string()) == "true"),
|
||||||
|
))
|
||||||
|
.execute(conn)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.context("Failed to load Channel while updating.")?;
|
||||||
|
if updated_rows != 1 {
|
||||||
|
return Err(AppError::NotFoundError(
|
||||||
|
"Channel with that team and ID not found".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Redirect::to(&format!(
|
||||||
|
"{}/teams/{}/channels/{}",
|
||||||
|
base_path,
|
||||||
|
team_id.simple(),
|
||||||
|
channel_id.simple()
|
||||||
|
))
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UpdateChannelEmailRecipientFormBody {
|
||||||
|
// Yes it's a mouthful, but it's only used twice
|
||||||
|
csrf_token: String,
|
||||||
|
recipient: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_channel_email_recipient(
|
||||||
|
State(Settings {
|
||||||
|
base_path,
|
||||||
|
email: email_settings,
|
||||||
|
..
|
||||||
|
}): State<Settings>,
|
||||||
|
DbConn(db_conn): DbConn,
|
||||||
|
State(Mailer(mailer)): State<Mailer>,
|
||||||
|
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
Form(form_body): Form<UpdateChannelEmailRecipientFormBody>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
guards::require_valid_csrf_token(&form_body.csrf_token, ¤t_user, &db_conn).await?;
|
||||||
|
guards::require_team_membership(¤t_user, &team_id, &db_conn).await?;
|
||||||
|
|
||||||
|
if !is_permissible_email(&form_body.recipient) {
|
||||||
|
return Err(AppError::BadRequestError(
|
||||||
|
"Unable to validate email address format.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let verification_code: String = rand::thread_rng()
|
||||||
|
.sample_iter(&Uniform::try_from(0..9).unwrap())
|
||||||
|
.take(6)
|
||||||
|
.map(|n| n.to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let verification_code_copy = verification_code.clone();
|
||||||
|
let recipient_copy = form_body.recipient.clone();
|
||||||
|
|
||||||
|
let channel_id_filter = Channel::with_id(channel_id.clone());
|
||||||
|
let email_channel_id_filter = email_channels::id.eq(channel_id.clone());
|
||||||
|
let team_filter = Channel::with_team(team_id.clone());
|
||||||
|
let updated_rows = db_conn
|
||||||
|
.interact(move |conn| {
|
||||||
|
if Channel::all()
|
||||||
|
.filter(channel_id_filter)
|
||||||
|
.filter(team_filter)
|
||||||
|
.filter(email_channel_id_filter.clone())
|
||||||
|
.first(conn)
|
||||||
|
.optional()
|
||||||
|
.context("failed to check whether channel exists under team")
|
||||||
|
.map_err(Into::<AppError>::into)?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Err(AppError::NotFoundError(
|
||||||
|
"Channel with that team and ID not found.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
update(email_channels::table.filter(email_channel_id_filter))
|
||||||
|
.set((
|
||||||
|
email_channels::recipient.eq(recipient_copy),
|
||||||
|
email_channels::verification_code.eq(verification_code_copy),
|
||||||
|
email_channels::verification_code_guesses.eq(0),
|
||||||
|
))
|
||||||
|
.execute(conn)
|
||||||
|
.map_err(Into::<AppError>::into)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()?;
|
||||||
|
if updated_rows != 1 {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Updating EmailChannel recipient, the channel was found but {} rows were updated.",
|
||||||
|
updated_rows
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"Email verification code for {} is: {}",
|
||||||
|
form_body.recipient,
|
||||||
|
verification_code
|
||||||
|
);
|
||||||
|
tracing::info!(
|
||||||
|
"Sending email verification code to: {}",
|
||||||
|
form_body.recipient
|
||||||
|
);
|
||||||
|
let email = lettre::Message::builder()
|
||||||
|
.from(email_settings.verification_from.clone().into())
|
||||||
|
.reply_to(email_settings.verification_from.clone().into())
|
||||||
|
.to(form_body.recipient.parse()?)
|
||||||
|
.subject("Verify Your Email")
|
||||||
|
.header(lettre::message::header::ContentType::TEXT_PLAIN)
|
||||||
|
.body(format!(
|
||||||
|
"Your email verification code is: {}",
|
||||||
|
verification_code
|
||||||
|
))?;
|
||||||
|
mailer.send(&email)?;
|
||||||
|
|
||||||
|
Ok(Redirect::to(&format!(
|
||||||
|
"{}/teams/{}/channels/{}",
|
||||||
|
base_path,
|
||||||
|
team_id.simple(),
|
||||||
|
channel_id.simple()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the email address matches a format recognized as "valid".
|
||||||
|
* Not all "legal" email addresses will be accepted, but addresses that are
|
||||||
|
* "illegal" and/or could result in unexpected behavior should be rejected.
|
||||||
|
*/
|
||||||
|
fn is_permissible_email(address: &str) -> bool {
|
||||||
|
let re = Regex::new(r"^[a-zA-Z0-9._+-]+@([a-zA-Z0-9_-]+.)+[a-zA-Z]+$")
|
||||||
|
.expect("email validation regex should parse");
|
||||||
|
re.is_match(address)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct VerifyEmailFormBody {
|
||||||
|
csrf_token: String,
|
||||||
|
code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_email(
|
||||||
|
State(Settings { base_path, .. }): State<Settings>,
|
||||||
|
DbConn(db_conn): DbConn,
|
||||||
|
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
Form(form_body): Form<VerifyEmailFormBody>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
guards::require_valid_csrf_token(&form_body.csrf_token, ¤t_user, &db_conn).await?;
|
||||||
|
guards::require_team_membership(¤t_user, &team_id, &db_conn).await?;
|
||||||
|
|
||||||
|
let channel_id_filter = Channel::with_id(channel_id.clone());
|
||||||
|
let email_channel_id_filter = email_channels::id.eq(channel_id.clone());
|
||||||
|
let team_filter = Channel::with_team(team_id.clone());
|
||||||
|
let updated_rows = db_conn
|
||||||
|
.interact(move |conn| {
|
||||||
|
if Channel::all()
|
||||||
|
.filter(channel_id_filter)
|
||||||
|
.filter(team_filter)
|
||||||
|
.filter(email_channel_id_filter.clone())
|
||||||
|
.first(conn)
|
||||||
|
.optional()
|
||||||
|
.context("failed to check whether channel exists under team")
|
||||||
|
.map_err(Into::<AppError>::into)?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Err(AppError::NotFoundError(
|
||||||
|
"Channel with that team and ID not found.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
update(email_channels::table.filter(email_channel_id_filter))
|
||||||
|
.set(
|
||||||
|
email_channels::verification_code_guesses
|
||||||
|
.eq(email_channels::verification_code_guesses + 1),
|
||||||
|
)
|
||||||
|
.execute(conn)
|
||||||
|
.map_err(Into::<AppError>::into)?;
|
||||||
|
update(
|
||||||
|
email_channels::table
|
||||||
|
.filter(email_channel_id_filter)
|
||||||
|
.filter(email_channels::verification_code.eq(form_body.code)),
|
||||||
|
)
|
||||||
|
.set(email_channels::verified.eq(true))
|
||||||
|
.execute(conn)
|
||||||
|
.map_err(Into::<AppError>::into)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()?;
|
||||||
|
if updated_rows != 1 {
|
||||||
|
return Err(AppError::BadRequestError(
|
||||||
|
"Verification code not accepted.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Redirect::to(&format!(
|
||||||
|
"{}/teams/{}/channels/{}",
|
||||||
|
base_path,
|
||||||
|
team_id.simple(),
|
||||||
|
channel_id.simple()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn project_page(
|
||||||
|
State(Settings { base_path, .. }): State<Settings>,
|
||||||
|
DbConn(db_conn): DbConn,
|
||||||
|
Path((team_id, project_id)): Path<(Uuid, Uuid)>,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?;
|
||||||
|
|
||||||
|
let project_id_filter = Project::with_id(project_id.clone());
|
||||||
|
let project_team_filter = Project::with_team(team_id.clone());
|
||||||
|
let project = db_conn
|
||||||
|
.interact(move |conn| {
|
||||||
|
match Project::all()
|
||||||
|
.filter(project_id_filter)
|
||||||
|
.filter(project_team_filter)
|
||||||
|
.first(conn)
|
||||||
|
{
|
||||||
|
diesel::QueryResult::Err(diesel::NotFound) => Err(AppError::NotFoundError(
|
||||||
|
"Project with that team and ID not found.".to_string(),
|
||||||
|
)),
|
||||||
|
other => other
|
||||||
|
.context("failed to load project")
|
||||||
|
.map_err(|err| err.into()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()?;
|
||||||
|
|
||||||
|
let selected_channels_query = project.selected_channels();
|
||||||
|
let enabled_channel_ids: HashSet<Uuid> = db_conn
|
||||||
|
.interact(move |conn| selected_channels_query.load(conn))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.context("failed to load selected channels")?
|
||||||
|
.iter()
|
||||||
|
.map(|channel| channel.id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let team_filter = Channel::with_team(team.id.clone());
|
||||||
|
let team_channels = db_conn
|
||||||
|
.interact(move |conn| Channel::all().filter(team_filter).load(conn))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.context("failed to load team channels")?;
|
||||||
|
|
||||||
|
let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?;
|
||||||
|
let nav_state = NavState::new()
|
||||||
|
.set_base_path(&base_path)
|
||||||
|
.push_team(&team)
|
||||||
|
.push_project(&project)?;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "project.html")]
|
||||||
|
struct ResponseTemplate {
|
||||||
|
base_path: String,
|
||||||
|
csrf_token: String,
|
||||||
|
enabled_channel_ids: HashSet<Uuid>,
|
||||||
|
nav_state: NavState,
|
||||||
|
project: Project,
|
||||||
|
team_channels: Vec<Channel>,
|
||||||
|
}
|
||||||
|
Ok(Html(
|
||||||
|
ResponseTemplate {
|
||||||
|
base_path,
|
||||||
|
csrf_token,
|
||||||
|
enabled_channel_ids,
|
||||||
|
project,
|
||||||
|
nav_state,
|
||||||
|
team_channels,
|
||||||
|
}
|
||||||
|
.render()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UpdateEnabledChannelsFormBody {
|
||||||
|
csrf_token: String,
|
||||||
|
#[serde(default)]
|
||||||
|
enabled_channels: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_enabled_channels(
|
||||||
|
State(Settings { base_path, .. }): State<Settings>,
|
||||||
|
DbConn(db_conn): DbConn,
|
||||||
|
Path((team_id, project_id)): Path<(Uuid, Uuid)>,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
Form(form_body): Form<UpdateEnabledChannelsFormBody>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
guards::require_valid_csrf_token(&form_body.csrf_token, ¤t_user, &db_conn).await?;
|
||||||
|
guards::require_team_membership(¤t_user, &team_id, &db_conn).await?;
|
||||||
|
|
||||||
|
let id_filter = Project::with_id(project_id.clone());
|
||||||
|
let team_filter = Project::with_team(team_id.clone());
|
||||||
|
db_conn
|
||||||
|
.interact(move |conn| -> Result<(), AppError> {
|
||||||
|
let project = match Project::all()
|
||||||
|
.filter(id_filter)
|
||||||
|
.filter(team_filter)
|
||||||
|
.first(conn)
|
||||||
|
{
|
||||||
|
diesel::QueryResult::Err(diesel::NotFound) => Err(AppError::NotFoundError(
|
||||||
|
"Project with that team and ID not found.".to_string(),
|
||||||
|
)),
|
||||||
|
other => other
|
||||||
|
.context("failed to load project")
|
||||||
|
.map_err(|err| err.into()),
|
||||||
|
}?;
|
||||||
|
delete(
|
||||||
|
channel_selections::table
|
||||||
|
.filter(ChannelSelection::with_project(project.id.clone()))
|
||||||
|
.filter(channel_selections::channel_id.ne_all(&form_body.enabled_channels)),
|
||||||
|
)
|
||||||
|
.execute(conn)
|
||||||
|
.context("failed to remove unset channel selections")?;
|
||||||
|
for channel_id in form_body.enabled_channels {
|
||||||
|
insert_into(channel_selections::table)
|
||||||
|
.values((
|
||||||
|
channel_selections::project_id.eq(&project.id),
|
||||||
|
channel_selections::channel_id.eq(channel_id),
|
||||||
|
))
|
||||||
|
.on_conflict_do_nothing()
|
||||||
|
.execute(conn)
|
||||||
|
.context("failed to insert channel selections")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()?;
|
||||||
|
|
||||||
|
Ok(Redirect::to(&format!(
|
||||||
|
"{}/teams/{}/projects/{}",
|
||||||
|
base_path, team_id, project_id
|
||||||
|
))
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
|
@ -18,6 +18,22 @@ diesel::table! {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
channel_selections (project_id, channel_id) {
|
||||||
|
project_id -> Uuid,
|
||||||
|
channel_id -> Uuid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
channels (id) {
|
||||||
|
id -> Uuid,
|
||||||
|
team_id -> Uuid,
|
||||||
|
name -> Text,
|
||||||
|
enable_by_default -> Bool,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
csrf_tokens (id) {
|
csrf_tokens (id) {
|
||||||
id -> Uuid,
|
id -> Uuid,
|
||||||
|
@ -26,6 +42,16 @@ diesel::table! {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
email_channels (id) {
|
||||||
|
id -> Uuid,
|
||||||
|
recipient -> Text,
|
||||||
|
verification_code -> Text,
|
||||||
|
verification_code_guesses -> Int4,
|
||||||
|
verified -> Bool,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
messages (id) {
|
messages (id) {
|
||||||
id -> Uuid,
|
id -> Uuid,
|
||||||
|
@ -43,6 +69,15 @@ diesel::table! {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
slack_channels (id) {
|
||||||
|
id -> Uuid,
|
||||||
|
oauth_state -> Text,
|
||||||
|
access_token -> Text,
|
||||||
|
conversation_id -> Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
team_memberships (team_id, user_id) {
|
team_memberships (team_id, user_id) {
|
||||||
team_id -> Uuid,
|
team_id -> Uuid,
|
||||||
|
@ -67,18 +102,27 @@ diesel::table! {
|
||||||
}
|
}
|
||||||
|
|
||||||
diesel::joinable!(api_keys -> teams (team_id));
|
diesel::joinable!(api_keys -> teams (team_id));
|
||||||
|
diesel::joinable!(channel_selections -> channels (channel_id));
|
||||||
|
diesel::joinable!(channel_selections -> projects (project_id));
|
||||||
|
diesel::joinable!(channels -> teams (team_id));
|
||||||
diesel::joinable!(csrf_tokens -> users (user_id));
|
diesel::joinable!(csrf_tokens -> users (user_id));
|
||||||
|
diesel::joinable!(email_channels -> channels (id));
|
||||||
diesel::joinable!(messages -> projects (project_id));
|
diesel::joinable!(messages -> projects (project_id));
|
||||||
diesel::joinable!(projects -> teams (team_id));
|
diesel::joinable!(projects -> teams (team_id));
|
||||||
|
diesel::joinable!(slack_channels -> channels (id));
|
||||||
diesel::joinable!(team_memberships -> teams (team_id));
|
diesel::joinable!(team_memberships -> teams (team_id));
|
||||||
diesel::joinable!(team_memberships -> users (user_id));
|
diesel::joinable!(team_memberships -> users (user_id));
|
||||||
|
|
||||||
diesel::allow_tables_to_appear_in_same_query!(
|
diesel::allow_tables_to_appear_in_same_query!(
|
||||||
api_keys,
|
api_keys,
|
||||||
browser_sessions,
|
browser_sessions,
|
||||||
|
channel_selections,
|
||||||
|
channels,
|
||||||
csrf_tokens,
|
csrf_tokens,
|
||||||
|
email_channels,
|
||||||
messages,
|
messages,
|
||||||
projects,
|
projects,
|
||||||
|
slack_channels,
|
||||||
team_memberships,
|
team_memberships,
|
||||||
teams,
|
teams,
|
||||||
users,
|
users,
|
||||||
|
|
|
@ -18,7 +18,9 @@ pub struct Settings {
|
||||||
#[serde(default = "default_port")]
|
#[serde(default = "default_port")]
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
|
|
||||||
pub auth: Auth,
|
pub auth: AuthSettings,
|
||||||
|
|
||||||
|
pub email: EmailSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_port() -> u16 {
|
fn default_port() -> u16 {
|
||||||
|
@ -30,7 +32,7 @@ fn default_host() -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
pub struct Auth {
|
pub struct AuthSettings {
|
||||||
pub client_id: String,
|
pub client_id: String,
|
||||||
pub client_secret: String,
|
pub client_secret: String,
|
||||||
pub redirect_url: String,
|
pub redirect_url: String,
|
||||||
|
@ -46,10 +48,27 @@ fn default_cookie_name() -> String {
|
||||||
"SHOUT_DOT_DEV_SESSION".to_string()
|
"SHOUT_DOT_DEV_SESSION".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct EmailSettings {
|
||||||
|
pub verification_from: lettre::Address,
|
||||||
|
pub message_from: lettre::Address,
|
||||||
|
pub smtp_server: String,
|
||||||
|
pub smtp_username: String,
|
||||||
|
pub smtp_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SlackSettings {
|
||||||
|
pub client_id: String,
|
||||||
|
pub client_secret: String,
|
||||||
|
pub redirect_url: String,
|
||||||
|
pub auth_url: String,
|
||||||
|
pub token_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
pub fn load() -> Result<Self, ConfigError> {
|
pub fn load() -> Result<Self, ConfigError> {
|
||||||
if let Err(_) = dotenv() {
|
if let Err(err) = dotenv() {
|
||||||
println!("Couldn't load .env file.");
|
println!("Couldn't load .env file: {:?}", err);
|
||||||
}
|
}
|
||||||
let s = Config::builder()
|
let s = Config::builder()
|
||||||
.add_source(Environment::default())
|
.add_source(Environment::default())
|
||||||
|
|
|
@ -1,21 +1,23 @@
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::Query,
|
extract::{Query, State},
|
||||||
response::{IntoResponse, Json},
|
response::{IntoResponse, Json},
|
||||||
routing::get,
|
routing::get,
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use diesel::{dsl::insert_into, prelude::*, update};
|
use diesel::{dsl::insert_into, prelude::*, update};
|
||||||
use serde::{Deserialize, Serialize};
|
use lettre::Transport as _;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api_keys::ApiKey,
|
api_keys::ApiKey,
|
||||||
app_error::AppError,
|
app_error::AppError,
|
||||||
app_state::{AppState, DbConn},
|
app_state::{AppState, DbConn, Mailer},
|
||||||
messages::Message,
|
|
||||||
projects::Project,
|
projects::Project,
|
||||||
schema::{api_keys, messages, projects},
|
schema::{api_keys, projects},
|
||||||
|
settings::Settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn new_router(state: AppState) -> Router<AppState> {
|
pub fn new_router(state: AppState) -> Router<AppState> {
|
||||||
|
@ -30,6 +32,11 @@ struct SayQuery {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn say_get(
|
async fn say_get(
|
||||||
|
State(Settings {
|
||||||
|
email: email_settings,
|
||||||
|
..
|
||||||
|
}): State<Settings>,
|
||||||
|
State(Mailer(mailer)): State<Mailer>,
|
||||||
DbConn(db_conn): DbConn,
|
DbConn(db_conn): DbConn,
|
||||||
Query(query): Query<SayQuery>,
|
Query(query): Query<SayQuery>,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
@ -50,7 +57,7 @@ async fn say_get(
|
||||||
None => return Err(AppError::ForbiddenError("key not accepted".to_string())),
|
None => return Err(AppError::ForbiddenError("key not accepted".to_string())),
|
||||||
};
|
};
|
||||||
let project_name = query.project.to_lowercase();
|
let project_name = query.project.to_lowercase();
|
||||||
let project = db_conn
|
let selected_channels = db_conn
|
||||||
.interact(move |conn| {
|
.interact(move |conn| {
|
||||||
insert_into(projects::table)
|
insert_into(projects::table)
|
||||||
.values((
|
.values((
|
||||||
|
@ -60,30 +67,43 @@ async fn say_get(
|
||||||
))
|
))
|
||||||
.on_conflict((projects::team_id, projects::name))
|
.on_conflict((projects::team_id, projects::name))
|
||||||
.do_nothing()
|
.do_nothing()
|
||||||
.execute(conn)?;
|
.execute(conn)
|
||||||
|
.context("failed to insert project")?;
|
||||||
// It would be nice to merge these two database operations into one,
|
// It would be nice to merge these two database operations into one,
|
||||||
// but it's not trivial to do so without faking an update; refer to:
|
// but it's not trivial to do so without faking an update; refer to:
|
||||||
// https://stackoverflow.com/a/42217872
|
// https://stackoverflow.com/a/42217872
|
||||||
Project::all()
|
let project = Project::all()
|
||||||
.filter(Project::with_team(api_key.team_id))
|
.filter(Project::with_team(api_key.team_id))
|
||||||
.filter(Project::with_name(project_name))
|
.filter(Project::with_name(project_name))
|
||||||
.first(conn)
|
.first(conn)
|
||||||
|
.context("failed to load project")?;
|
||||||
|
project
|
||||||
|
.selected_channels()
|
||||||
|
.load(conn)
|
||||||
|
.context("failed to load selected channels")
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.context("unable to get project")?;
|
.context("unable to get project")?;
|
||||||
db_conn
|
|
||||||
.interact(move |conn| {
|
for channel in selected_channels {
|
||||||
insert_into(messages::table)
|
if let Some(email_data) = channel.email_data {
|
||||||
.values(Message::values_now(project.id, query.message))
|
if email_data.verified {
|
||||||
.execute(conn)
|
let recipient: lettre::Address = email_data.recipient.parse()?;
|
||||||
})
|
let email = lettre::Message::builder()
|
||||||
.await
|
.from(email_settings.message_from.clone().into())
|
||||||
.unwrap()
|
.reply_to(email_settings.message_from.clone().into())
|
||||||
.context("unable to insert message")?;
|
.to(recipient.into())
|
||||||
#[derive(Serialize)]
|
.subject("Shout")
|
||||||
struct ResponseBody {
|
.header(lettre::message::header::ContentType::TEXT_PLAIN)
|
||||||
ok: bool,
|
.body(query.message.clone())?;
|
||||||
|
tracing::info!("Sending email to recipient for channel {}", channel.id);
|
||||||
|
mailer.send(&email)?;
|
||||||
|
} else {
|
||||||
|
tracing::info!("Email recipient for channel {} is not verified", channel.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(Json(ResponseBody { ok: true }))
|
|
||||||
|
Ok(Json(json!({ "ok": true })))
|
||||||
}
|
}
|
||||||
|
|
125
templates/channel-email.html
Normal file
125
templates/channel-email.html
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Shout.dev: Channels{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
{% let email_data = channel.email_data.clone().unwrap() %}
|
||||||
|
{% include "breadcrumbs.html" %}
|
||||||
|
<main class="container mt-5">
|
||||||
|
<section class="mb-4">
|
||||||
|
<h1>Channel Configuration</h1>
|
||||||
|
</section>
|
||||||
|
<section class="mb-4">
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-channel"
|
||||||
|
>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="channel-name-input" class="form-label">Channel Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="channel-name-input"
|
||||||
|
name="name"
|
||||||
|
value="{{ channel.name }}"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
{% if channel.enable_by_default %}
|
||||||
|
checked=""
|
||||||
|
{% endif %}
|
||||||
|
type="checkbox"
|
||||||
|
name="enable_by_default"
|
||||||
|
value="true"
|
||||||
|
role="switch"
|
||||||
|
id="channel-default-enabled-switch"
|
||||||
|
>
|
||||||
|
<label class="form-check-label" for="channel-default-enabled-switch">
|
||||||
|
Enable by default for new projects in this team
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button class="btn btn-primary" type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="mb-4">
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-email-recipient"
|
||||||
|
>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="channel-recipient-input" class="form-label">Recipient Email</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="channel-recipient-input"
|
||||||
|
name="recipient"
|
||||||
|
value="{{ email_data.recipient }}"
|
||||||
|
aria-describedby="channel-recipient-help"
|
||||||
|
>
|
||||||
|
<div id="channel-recipient-help" class="form-text">
|
||||||
|
{% if email_data.verified %}
|
||||||
|
Updating this will require verification of the new recipient.
|
||||||
|
{% else %}
|
||||||
|
Recipient must be verified before they can receive messages.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button class="btn btn-primary" type="submit">Send Verification</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% if email_data.recipient != "" && !email_data.verified %}
|
||||||
|
<section class="mb-4">
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/verify-email"
|
||||||
|
>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="channel-recipient-verification-code" class="form-label">
|
||||||
|
Verification Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
name="code"
|
||||||
|
id="channel-recipient-verification-code"
|
||||||
|
aria-describedby="channel-recipient-verification-code-help"
|
||||||
|
>
|
||||||
|
<div id="channel-recipient-verification-code-help" class="form-text">
|
||||||
|
Enter the most recent Shout.dev verification code for this address.
|
||||||
|
<input
|
||||||
|
type="submit"
|
||||||
|
form="email-verification-form"
|
||||||
|
class="btn btn-link align-baseline p-0"
|
||||||
|
type="submit"
|
||||||
|
style="font-size: inherit;"
|
||||||
|
value="Re-send verification email"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button class="btn btn-primary" type="submit">Verify</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
id="email-verification-form"
|
||||||
|
method="post"
|
||||||
|
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-email-recipient"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="recipient" value="{{ email_data.recipient }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
63
templates/channels.html
Normal file
63
templates/channels.html
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Shout.dev: Channels{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
{% include "breadcrumbs.html" %}
|
||||||
|
<main class="container mt-5">
|
||||||
|
<section class="mb-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h1>Channels</h1>
|
||||||
|
<div>
|
||||||
|
<div class="dropdown">
|
||||||
|
<!-- FIXME: a11y https://getbootstrap.com/docs/5.3/components/dropdowns/#accessibility -->
|
||||||
|
<button
|
||||||
|
class="btn btn-primary dropdown-toggle"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
New Channel
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item disabled" href="#">Slack (coming soon)</a></li>
|
||||||
|
<li>
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/new-channel"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="channel_type" value="email">
|
||||||
|
<button class="dropdown-item" type="submit">Email</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<li><a class="dropdown-item disabled" href="#">SMS (coming soon)</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div class="alert alert-primary" role="alert">
|
||||||
|
Channels are places to send messages, alerts, and so on. Once created, they
|
||||||
|
can be connected to specific projects at the
|
||||||
|
<a
|
||||||
|
href="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/projects"
|
||||||
|
>Projects page</a>.
|
||||||
|
</div>
|
||||||
|
<section class="mb-3">
|
||||||
|
<table class="table">
|
||||||
|
<tbody>
|
||||||
|
{% for channel in channels %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}">
|
||||||
|
{{ channel.name }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
62
templates/project.html
Normal file
62
templates/project.html
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Shout.dev: Projects: {{ project.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
{% include "breadcrumbs.html" %}
|
||||||
|
<main class="container mt-5">
|
||||||
|
<section class="mb-4">
|
||||||
|
<h1>Project: <code>{{ project.name }}</code></h1>
|
||||||
|
</section>
|
||||||
|
<section class="mb-4">
|
||||||
|
<h2>Enabled Channels</h2>
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/projects/{{ project.id.simple() }}/update-enabled-channels"
|
||||||
|
>
|
||||||
|
<div class="mb-3">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Channel Name</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for channel in team_channels %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<label for="enable-channel-switch-{{ channel.id.simple() }}">
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
href="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}"
|
||||||
|
>
|
||||||
|
{{ channel.name }}
|
||||||
|
</a>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
{% if enabled_channel_ids.contains(channel.id) %}
|
||||||
|
checked=""
|
||||||
|
{% endif %}
|
||||||
|
type="checkbox"
|
||||||
|
name="enabled_channels"
|
||||||
|
value="{{ channel.id.simple() }}"
|
||||||
|
id="enable-channel-switch-{{ channel.id.simple() }}"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button class="btn btn-primary" type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
|
@ -3,11 +3,7 @@
|
||||||
{% block title %}Shout.dev: Teams{% endblock %}
|
{% block title %}Shout.dev: Teams{% endblock %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<nav class="container mt-4" aria-label="breadcrumb">
|
{% include "breadcrumbs.html" %}
|
||||||
<ol class="breadcrumb">
|
|
||||||
<li class="breadcrumb-item active" aria-current="page">Teams</li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
<main class="container mt-5">
|
<main class="container mt-5">
|
||||||
<section class="mb-4">
|
<section class="mb-4">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
|
Loading…
Add table
Reference in a new issue