forked from 2sys/shoutdotdev
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",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"headers",
|
||||
"http 1.1.0",
|
||||
|
@ -313,6 +314,8 @@ dependencies = [
|
|||
"mime",
|
||||
"pin-project-lite",
|
||||
"serde",
|
||||
"serde_html_form",
|
||||
"serde_path_to_error",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
|
@ -503,6 +506,16 @@ dependencies = [
|
|||
"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]]
|
||||
name = "config"
|
||||
version = "0.14.1"
|
||||
|
@ -789,6 +802,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "dlv-list"
|
||||
version = "0.5.2"
|
||||
|
@ -824,6 +848,22 @@ version = "1.13.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.34"
|
||||
|
@ -1128,6 +1168,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "http"
|
||||
version = "0.2.12"
|
||||
|
@ -1345,6 +1396,124 @@ dependencies = [
|
|||
"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]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
|
@ -1361,6 +1530,27 @@ dependencies = [
|
|||
"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]]
|
||||
name = "indexmap"
|
||||
version = "2.6.0"
|
||||
|
@ -1409,6 +1599,36 @@ version = "1.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "libc"
|
||||
version = "0.2.161"
|
||||
|
@ -1427,6 +1647,12 @@ version = "0.4.14"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.22"
|
||||
|
@ -1768,6 +1994,15 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psm"
|
||||
version = "0.1.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.37"
|
||||
|
@ -1777,6 +2012,12 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quoted_printable"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
|
@ -2135,6 +2376,19 @@ dependencies = [
|
|||
"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]]
|
||||
name = "serde_json"
|
||||
version = "1.0.132"
|
||||
|
@ -2246,8 +2500,10 @@ dependencies = [
|
|||
"diesel",
|
||||
"dotenvy",
|
||||
"futures",
|
||||
"lettre",
|
||||
"oauth2",
|
||||
"rand",
|
||||
"regex",
|
||||
"reqwest 0.12.8",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
@ -2291,6 +2547,25 @@ version = "0.9.8"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
|
@ -2329,6 +2604,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "system-configuration"
|
||||
version = "0.5.1"
|
||||
|
@ -2454,6 +2740,16 @@ dependencies = [
|
|||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.8.0"
|
||||
|
@ -2757,11 +3053,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"idna 0.5.0",
|
||||
"percent-encoding",
|
||||
"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]]
|
||||
name = "uuid"
|
||||
version = "1.11.0"
|
||||
|
@ -2911,6 +3219,16 @@ version = "0.4.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows-core"
|
||||
version = "0.52.0"
|
||||
|
@ -3117,6 +3435,18 @@ dependencies = [
|
|||
"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]]
|
||||
name = "yaml-rust2"
|
||||
version = "0.8.1"
|
||||
|
@ -3128,6 +3458,30 @@ dependencies = [
|
|||
"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]]
|
||||
name = "zerocopy"
|
||||
version = "0.7.35"
|
||||
|
@ -3149,8 +3503,51 @@ dependencies = [
|
|||
"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]]
|
||||
name = "zeroize"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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"] }
|
||||
deadpool-diesel = { version = "0.6.1", features = ["postgres", "serde"] }
|
||||
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"] }
|
||||
base64 = "0.22.1"
|
||||
diesel = { version = "2.2.6", features = ["postgres", "chrono", "uuid"] }
|
||||
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::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
|
||||
#[derive(Debug)]
|
||||
pub enum AppError {
|
||||
InternalServerError(Error),
|
||||
InternalServerError(anyhow::Error),
|
||||
ForbiddenError(String),
|
||||
NotFoundError(String),
|
||||
BadRequestError(String),
|
||||
}
|
||||
|
||||
// Tell axum how to convert `AppError` into a response.
|
||||
|
@ -22,6 +25,14 @@ impl IntoResponse for AppError {
|
|||
tracing::info!("Forbidden: {}", client_message);
|
||||
(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))
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
use deadpool_diesel::postgres::{Connection, Pool};
|
||||
use lettre::SmtpTransport;
|
||||
use oauth2::basic::BasicClient;
|
||||
|
||||
use crate::{app_error::AppError, sessions::PgStore, settings::Settings};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct AppState {
|
||||
pub struct AppState {
|
||||
pub db_pool: Pool,
|
||||
pub mailer: Mailer,
|
||||
pub oauth_client: BasicClient,
|
||||
pub session_store: PgStore,
|
||||
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 {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
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_state;
|
||||
mod auth;
|
||||
mod channel_selections;
|
||||
mod channels;
|
||||
mod csrf;
|
||||
mod guards;
|
||||
mod messages;
|
||||
|
@ -18,7 +20,12 @@ mod v0_router;
|
|||
|
||||
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]
|
||||
async fn main() {
|
||||
|
@ -37,9 +44,21 @@ async fn main() {
|
|||
|
||||
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 app_state = AppState {
|
||||
db_pool,
|
||||
mailer,
|
||||
oauth_client,
|
||||
session_store,
|
||||
settings: settings.clone(),
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
use diesel::{
|
||||
dsl::{auto_type, AsSelect},
|
||||
dsl::{auto_type, AsSelect, Eq},
|
||||
pg::Pg,
|
||||
prelude::*,
|
||||
};
|
||||
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)]
|
||||
#[diesel(table_name = projects)]
|
||||
|
@ -23,6 +27,11 @@ impl Project {
|
|||
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)]
|
||||
pub fn with_team(team_id: Uuid) -> _ {
|
||||
projects::team_id.eq(team_id)
|
||||
|
@ -32,4 +41,17 @@ impl Project {
|
|||
pub fn with_name(name: String) -> _ {
|
||||
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 axum::{
|
||||
extract::{Path, State},
|
||||
response::{Html, IntoResponse, Redirect},
|
||||
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 tower::ServiceBuilder;
|
||||
use tower_http::{
|
||||
|
@ -19,13 +25,15 @@ use uuid::Uuid;
|
|||
use crate::{
|
||||
api_keys::ApiKey,
|
||||
app_error::AppError,
|
||||
app_state::{AppState, DbConn},
|
||||
app_state::{AppState, DbConn, Mailer},
|
||||
auth,
|
||||
channel_selections::ChannelSelection,
|
||||
channels::Channel,
|
||||
csrf::generate_csrf_token,
|
||||
guards,
|
||||
nav_state::{Breadcrumb, NavState},
|
||||
projects::Project,
|
||||
schema,
|
||||
schema::{self, channel_selections, channels, email_channels},
|
||||
settings::Settings,
|
||||
team_memberships::TeamMembership,
|
||||
teams::Team,
|
||||
|
@ -36,14 +44,34 @@ use crate::{
|
|||
pub fn new_router(state: AppState) -> Router<()> {
|
||||
let base_path = state.settings.base_path.clone();
|
||||
Router::new().nest(
|
||||
format!("{}", base_path).as_str(),
|
||||
base_path.as_str(),
|
||||
Router::new()
|
||||
.route("/", get(landing_page))
|
||||
.merge(v0_router::new_router(state.clone()))
|
||||
.route("/teams", get(teams_page))
|
||||
.route("/teams/{team_id}", get(team_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}/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", post(post_new_team))
|
||||
.nest("/auth", auth::new_router())
|
||||
|
@ -250,3 +278,515 @@ async fn projects_page(
|
|||
)
|
||||
.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! {
|
||||
csrf_tokens (id) {
|
||||
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! {
|
||||
messages (id) {
|
||||
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! {
|
||||
team_memberships (team_id, user_id) {
|
||||
team_id -> Uuid,
|
||||
|
@ -67,18 +102,27 @@ diesel::table! {
|
|||
}
|
||||
|
||||
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!(email_channels -> channels (id));
|
||||
diesel::joinable!(messages -> projects (project_id));
|
||||
diesel::joinable!(projects -> teams (team_id));
|
||||
diesel::joinable!(slack_channels -> channels (id));
|
||||
diesel::joinable!(team_memberships -> teams (team_id));
|
||||
diesel::joinable!(team_memberships -> users (user_id));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
api_keys,
|
||||
browser_sessions,
|
||||
channel_selections,
|
||||
channels,
|
||||
csrf_tokens,
|
||||
email_channels,
|
||||
messages,
|
||||
projects,
|
||||
slack_channels,
|
||||
team_memberships,
|
||||
teams,
|
||||
users,
|
||||
|
|
|
@ -18,7 +18,9 @@ pub struct Settings {
|
|||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
|
||||
pub auth: Auth,
|
||||
pub auth: AuthSettings,
|
||||
|
||||
pub email: EmailSettings,
|
||||
}
|
||||
|
||||
fn default_port() -> u16 {
|
||||
|
@ -30,7 +32,7 @@ fn default_host() -> String {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Auth {
|
||||
pub struct AuthSettings {
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
pub redirect_url: String,
|
||||
|
@ -46,10 +48,27 @@ fn default_cookie_name() -> 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 {
|
||||
pub fn load() -> Result<Self, ConfigError> {
|
||||
if let Err(_) = dotenv() {
|
||||
println!("Couldn't load .env file.");
|
||||
if let Err(err) = dotenv() {
|
||||
println!("Couldn't load .env file: {:?}", err);
|
||||
}
|
||||
let s = Config::builder()
|
||||
.add_source(Environment::default())
|
||||
|
|
|
@ -1,21 +1,23 @@
|
|||
use anyhow::Context;
|
||||
use axum::{
|
||||
extract::Query,
|
||||
extract::{Query, State},
|
||||
response::{IntoResponse, Json},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
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 crate::{
|
||||
api_keys::ApiKey,
|
||||
app_error::AppError,
|
||||
app_state::{AppState, DbConn},
|
||||
messages::Message,
|
||||
app_state::{AppState, DbConn, Mailer},
|
||||
projects::Project,
|
||||
schema::{api_keys, messages, projects},
|
||||
schema::{api_keys, projects},
|
||||
settings::Settings,
|
||||
};
|
||||
|
||||
pub fn new_router(state: AppState) -> Router<AppState> {
|
||||
|
@ -30,6 +32,11 @@ struct SayQuery {
|
|||
}
|
||||
|
||||
async fn say_get(
|
||||
State(Settings {
|
||||
email: email_settings,
|
||||
..
|
||||
}): State<Settings>,
|
||||
State(Mailer(mailer)): State<Mailer>,
|
||||
DbConn(db_conn): DbConn,
|
||||
Query(query): Query<SayQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
|
@ -50,7 +57,7 @@ async fn say_get(
|
|||
None => return Err(AppError::ForbiddenError("key not accepted".to_string())),
|
||||
};
|
||||
let project_name = query.project.to_lowercase();
|
||||
let project = db_conn
|
||||
let selected_channels = db_conn
|
||||
.interact(move |conn| {
|
||||
insert_into(projects::table)
|
||||
.values((
|
||||
|
@ -60,30 +67,43 @@ async fn say_get(
|
|||
))
|
||||
.on_conflict((projects::team_id, projects::name))
|
||||
.do_nothing()
|
||||
.execute(conn)?;
|
||||
.execute(conn)
|
||||
.context("failed to insert project")?;
|
||||
// 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:
|
||||
// https://stackoverflow.com/a/42217872
|
||||
Project::all()
|
||||
let project = Project::all()
|
||||
.filter(Project::with_team(api_key.team_id))
|
||||
.filter(Project::with_name(project_name))
|
||||
.first(conn)
|
||||
.context("failed to load project")?;
|
||||
project
|
||||
.selected_channels()
|
||||
.load(conn)
|
||||
.context("failed to load selected channels")
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.context("unable to get project")?;
|
||||
db_conn
|
||||
.interact(move |conn| {
|
||||
insert_into(messages::table)
|
||||
.values(Message::values_now(project.id, query.message))
|
||||
.execute(conn)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.context("unable to insert message")?;
|
||||
#[derive(Serialize)]
|
||||
struct ResponseBody {
|
||||
ok: bool,
|
||||
|
||||
for channel in selected_channels {
|
||||
if let Some(email_data) = channel.email_data {
|
||||
if email_data.verified {
|
||||
let recipient: lettre::Address = email_data.recipient.parse()?;
|
||||
let email = lettre::Message::builder()
|
||||
.from(email_settings.message_from.clone().into())
|
||||
.reply_to(email_settings.message_from.clone().into())
|
||||
.to(recipient.into())
|
||||
.subject("Shout")
|
||||
.header(lettre::message::header::ContentType::TEXT_PLAIN)
|
||||
.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 main %}
|
||||
<nav class="container mt-4" aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item active" aria-current="page">Teams</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% include "breadcrumbs.html" %}
|
||||
<main class="container mt-5">
|
||||
<section class="mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
|
|
Loading…
Add table
Reference in a new issue