Compare commits
No commits in common. "962bdcb24f67f67b483b4e98fcfd630b218b355e" and "0312cdd1aafe328cc7625448bbd9ae25ba9dbc77" have entirely different histories.
962bdcb24f
...
0312cdd1aa
15 changed files with 397 additions and 472 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -758,7 +758,6 @@ dependencies = [
|
||||||
"diesel_derives",
|
"diesel_derives",
|
||||||
"itoa",
|
"itoa",
|
||||||
"pq-sys",
|
"pq-sys",
|
||||||
"serde_json",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -28,7 +28,7 @@ axum = { version = "0.8.1", features = ["macros"] }
|
||||||
axum-extra = { version = "0.10.0", features = ["cookie", "form", "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", "serde_json"] }
|
diesel = { version = "2.2.6", features = ["postgres", "chrono", "uuid"] }
|
||||||
tower = "0.5.2"
|
tower = "0.5.2"
|
||||||
regex = "1.11.1"
|
regex = "1.11.1"
|
||||||
lettre = { version = "0.11.12", features = ["tokio1", "serde", "tracing", "tokio1-native-tls"] }
|
lettre = { version = "0.11.12", features = ["tokio1", "serde", "tracing", "tokio1-native-tls"] }
|
||||||
|
|
|
@ -3,9 +3,3 @@
|
||||||
Shout.dev is a tool for small software teams to stay in touch with the software
|
Shout.dev is a tool for small software teams to stay in touch with the software
|
||||||
they write. Once set up, it provides an extremely simple HTTP interface through
|
they write. Once set up, it provides an extremely simple HTTP interface through
|
||||||
which to broadcast messages via email and other means of communication.
|
which to broadcast messages via email and other means of communication.
|
||||||
|
|
||||||
## Project Status
|
|
||||||
|
|
||||||
Shout.dev is in the early prototyping stage. Basic documentation and testing
|
|
||||||
will be prioritized after core functionality is completed and shown to have
|
|
||||||
practical value.
|
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
DROP TABLE IF EXISTS channel_selections;
|
|
||||||
DROP TABLE IF EXISTS channels;
|
|
||||||
DROP TABLE IF EXISTS projects;
|
DROP TABLE IF EXISTS projects;
|
||||||
DROP TABLE IF EXISTS api_keys;
|
DROP TABLE IF EXISTS api_keys;
|
||||||
DROP TABLE IF EXISTS team_memberships;
|
DROP TABLE IF EXISTS team_memberships;
|
||||||
|
|
|
@ -41,19 +41,3 @@ CREATE TABLE projects (
|
||||||
UNIQUE (team_id, name)
|
UNIQUE (team_id, name)
|
||||||
);
|
);
|
||||||
CREATE INDEX ON projects(team_id);
|
CREATE INDEX ON projects(team_id);
|
||||||
|
|
||||||
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,
|
|
||||||
backend_config JSONB NOT NULL DEFAULT '{}'::JSONB
|
|
||||||
);
|
|
||||||
|
|
||||||
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,10 +1,8 @@
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
id UUID PRIMARY KEY NOT NULL,
|
id UUID PRIMARY KEY NOT NULL,
|
||||||
channel_id UUID NOT NULL REFERENCES channels (id),
|
project_id UUID NOT NULL REFERENCES projects (id),
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
sent_at TIMESTAMPTZ,
|
|
||||||
message TEXT NOT NULL
|
message TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX ON messages (channel_id);
|
CREATE INDEX ON messages (project_id);
|
||||||
CREATE INDEX ON messages (created_at);
|
CREATE INDEX ON messages (created_at);
|
||||||
CREATE INDEX ON messages (sent_at);
|
|
||||||
|
|
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);
|
204
src/channels.rs
204
src/channels.rs
|
@ -1,44 +1,64 @@
|
||||||
|
use anyhow::Context;
|
||||||
|
use deadpool_diesel::postgres::Connection;
|
||||||
use diesel::{
|
use diesel::{
|
||||||
backend::Backend,
|
dsl::{auto_type, insert_into, AsSelect},
|
||||||
deserialize::{self, FromSql, FromSqlRow},
|
|
||||||
dsl::{auto_type, AsSelect},
|
|
||||||
expression::AsExpression,
|
|
||||||
pg::Pg,
|
pg::Pg,
|
||||||
prelude::*,
|
prelude::*,
|
||||||
serialize::{Output, ToSql},
|
Connection as _,
|
||||||
sql_types::Jsonb,
|
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Serialize;
|
||||||
use serde_json::json;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{schema::channels, teams::Team};
|
use crate::{
|
||||||
|
app_error::AppError,
|
||||||
|
schema::{channels, email_channels, slack_channels},
|
||||||
|
teams::Team,
|
||||||
|
};
|
||||||
|
|
||||||
pub const CHANNEL_BACKEND_EMAIL: &'static str = "email";
|
|
||||||
pub const CHANNEL_BACKEND_SLACK: &'static str = "slack";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a target/destination for messages, with the sender configuration
|
|
||||||
* defined in the backend_config field. A single channel may be attached to
|
|
||||||
* (in other words, "enabled" or "selected" for) any number of projects within
|
|
||||||
* the same team.
|
|
||||||
*/
|
|
||||||
#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)]
|
#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)]
|
||||||
#[diesel(belongs_to(Team))]
|
#[diesel(belongs_to(Team))]
|
||||||
#[diesel(check_for_backend(Pg))]
|
|
||||||
pub struct Channel {
|
pub struct Channel {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub team_id: Uuid,
|
pub team_id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub enable_by_default: bool,
|
pub enable_by_default: bool,
|
||||||
pub backend_config: BackendConfig,
|
|
||||||
|
#[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 {
|
impl Channel {
|
||||||
#[auto_type(no_type_alias)]
|
#[auto_type(no_type_alias)]
|
||||||
pub fn all() -> _ {
|
pub fn all() -> _ {
|
||||||
let select: AsSelect<Channel, Pg> = Channel::as_select();
|
let select: AsSelect<Channel, Pg> = Channel::as_select();
|
||||||
channels::table.select(select)
|
channels::table
|
||||||
|
.left_join(email_channels::table)
|
||||||
|
.left_join(slack_channels::table)
|
||||||
|
.select(select)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[auto_type(no_type_alias)]
|
#[auto_type(no_type_alias)]
|
||||||
|
@ -50,123 +70,31 @@ impl Channel {
|
||||||
pub fn with_team(team_id: Uuid) -> _ {
|
pub fn with_team(team_id: Uuid) -> _ {
|
||||||
channels::team_id.eq(team_id)
|
channels::team_id.eq(team_id)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
pub async fn create_email_channel(
|
||||||
* Encapsulates any information that needs to be persisted for setting up or
|
db_conn: &Connection,
|
||||||
* using a channel's backend (that is, email sender, Slack app, etc.). This
|
team_id: Uuid,
|
||||||
* configuration is encoded to a jsonb column in the database, which determines
|
) -> Result<Self, AppError> {
|
||||||
* the channel type along with configuration details.
|
let id = Uuid::now_v7();
|
||||||
*
|
db_conn
|
||||||
* Note: In a previous implementation, channel configuration was handled by
|
.interact(move |conn| {
|
||||||
* creating a dedicated table for each channel type and joining them to the
|
conn.transaction(move |conn| {
|
||||||
* `channels` table in order to access configuration fields. The jsonb approach
|
insert_into(channels::table)
|
||||||
* simplifies database management and lends itself to a cleaner Rust
|
.values((
|
||||||
* implementation in which this enum can be treated as a column type with
|
channels::id.eq(id.clone()),
|
||||||
* enforcement of data structure invariants handled entirely in the to_sql()
|
channels::team_id.eq(team_id),
|
||||||
* and from_sql() serialization/deserialization logic.
|
channels::name.eq("Untitled Email Channel"),
|
||||||
*/
|
))
|
||||||
#[derive(AsExpression, Clone, Debug, FromSqlRow, Deserialize, Serialize)]
|
.execute(conn)?;
|
||||||
#[diesel(sql_type = Jsonb)]
|
insert_into(email_channels::table)
|
||||||
pub enum BackendConfig {
|
.values(email_channels::id.eq(id.clone()))
|
||||||
Email(EmailBackendConfig),
|
.execute(conn)?;
|
||||||
Slack(SlackBackendConfig),
|
Self::all().filter(Self::with_id(id)).first(conn)
|
||||||
}
|
})
|
||||||
|
})
|
||||||
impl ToSql<Jsonb, Pg> for BackendConfig {
|
.await
|
||||||
fn to_sql<'a>(&self, out: &mut Output<'a, '_, Pg>) -> diesel::serialize::Result {
|
.unwrap()
|
||||||
match self.clone() {
|
.context("Failed to insert new EmailChannel.")
|
||||||
BackendConfig::Email(config) => ToSql::<Jsonb, Pg>::to_sql(
|
.map_err(|err| err.into())
|
||||||
&json!({
|
|
||||||
"t": CHANNEL_BACKEND_EMAIL,
|
|
||||||
"config": config,
|
|
||||||
}),
|
|
||||||
&mut out.reborrow(),
|
|
||||||
),
|
|
||||||
BackendConfig::Slack(config) => ToSql::<Jsonb, Pg>::to_sql(
|
|
||||||
&json!({
|
|
||||||
"t": CHANNEL_BACKEND_SLACK,
|
|
||||||
"config": config,
|
|
||||||
}),
|
|
||||||
&mut out.reborrow(),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromSql<Jsonb, Pg> for BackendConfig {
|
|
||||||
fn from_sql(bytes: <Pg as Backend>::RawValue<'_>) -> deserialize::Result<Self> {
|
|
||||||
let value = <serde_json::Value as FromSql<Jsonb, Pg>>::from_sql(bytes)?;
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct IntermediateBackendConfig {
|
|
||||||
pub t: String,
|
|
||||||
pub config: serde_json::Value,
|
|
||||||
}
|
|
||||||
let intermediate_value = IntermediateBackendConfig::deserialize(value)?;
|
|
||||||
match intermediate_value.t.as_str() {
|
|
||||||
CHANNEL_BACKEND_EMAIL => Ok(BackendConfig::Email(EmailBackendConfig::deserialize(
|
|
||||||
intermediate_value.config,
|
|
||||||
)?)),
|
|
||||||
CHANNEL_BACKEND_SLACK => Ok(BackendConfig::Slack(SlackBackendConfig::deserialize(
|
|
||||||
intermediate_value.config,
|
|
||||||
)?)),
|
|
||||||
t => Err(anyhow::anyhow!("backend type not recognized: {}", t).into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
|
||||||
pub struct EmailBackendConfig {
|
|
||||||
pub recipient: String,
|
|
||||||
pub verification_code: String,
|
|
||||||
pub verification_code_guesses: u32,
|
|
||||||
pub verified: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<BackendConfig> for EmailBackendConfig {
|
|
||||||
type Error = anyhow::Error;
|
|
||||||
|
|
||||||
fn try_from(value: BackendConfig) -> Result<Self, Self::Error> {
|
|
||||||
if let BackendConfig::Email(config) = value {
|
|
||||||
Ok(config)
|
|
||||||
} else {
|
|
||||||
Err(anyhow::anyhow!(
|
|
||||||
"could not load backend config as email channel"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Into<BackendConfig> for EmailBackendConfig {
|
|
||||||
fn into(self) -> BackendConfig {
|
|
||||||
BackendConfig::Email(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub struct SlackBackendConfig {
|
|
||||||
pub oauth_state: String,
|
|
||||||
pub access_token: String,
|
|
||||||
pub refresh_token: String,
|
|
||||||
pub conversation_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<BackendConfig> for SlackBackendConfig {
|
|
||||||
type Error = anyhow::Error;
|
|
||||||
|
|
||||||
fn try_from(value: BackendConfig) -> Result<Self, Self::Error> {
|
|
||||||
if let BackendConfig::Slack(config) = value {
|
|
||||||
Ok(config)
|
|
||||||
} else {
|
|
||||||
Err(anyhow::anyhow!(
|
|
||||||
"could not load backend config as slack channel"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Into<BackendConfig> for SlackBackendConfig {
|
|
||||||
fn into(self) -> BackendConfig {
|
|
||||||
BackendConfig::Slack(self)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,14 +6,14 @@ use diesel::{
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{channels::Channel, schema::messages};
|
use crate::{projects::Project, schema::messages};
|
||||||
|
|
||||||
#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)]
|
#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)]
|
||||||
#[diesel(table_name = messages)]
|
#[diesel(table_name = messages)]
|
||||||
#[diesel(belongs_to(Channel))]
|
#[diesel(belongs_to(Project))]
|
||||||
pub struct Message {
|
pub struct Message {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub channel_id: Uuid,
|
pub project_id: Uuid,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
@ -26,7 +26,17 @@ impl Message {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[auto_type(no_type_alias)]
|
#[auto_type(no_type_alias)]
|
||||||
pub fn with_channel(channel_id: Uuid) -> _ {
|
pub fn with_project(project_id: Uuid) -> _ {
|
||||||
messages::channel_id.eq(channel_id)
|
messages::project_id.eq(project_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[auto_type(no_type_alias)]
|
||||||
|
pub fn values_now(project_id: Uuid, message: String) -> _ {
|
||||||
|
let id: Uuid = Uuid::now_v7();
|
||||||
|
(
|
||||||
|
messages::id.eq(id),
|
||||||
|
messages::project_id.eq(project_id),
|
||||||
|
messages::message.eq(message),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
channels::Channel,
|
channels::Channel,
|
||||||
schema::{channel_selections, channels, projects},
|
schema::{channel_selections, channels, email_channels, projects, slack_channels},
|
||||||
teams::Team,
|
teams::Team,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -48,6 +48,8 @@ impl Project {
|
||||||
let project_filter: Eq<channel_selections::project_id, Uuid> =
|
let project_filter: Eq<channel_selections::project_id, Uuid> =
|
||||||
channel_selections::project_id.eq(self.id);
|
channel_selections::project_id.eq(self.id);
|
||||||
channels::table
|
channels::table
|
||||||
|
.left_join(email_channels::table)
|
||||||
|
.left_join(slack_channels::table)
|
||||||
.inner_join(channel_selections::table)
|
.inner_join(channel_selections::table)
|
||||||
.filter(project_filter)
|
.filter(project_filter)
|
||||||
.select(select)
|
.select(select)
|
||||||
|
|
255
src/router.rs
255
src/router.rs
|
@ -27,13 +27,13 @@ use crate::{
|
||||||
app_state::{AppState, DbConn},
|
app_state::{AppState, DbConn},
|
||||||
auth,
|
auth,
|
||||||
channel_selections::ChannelSelection,
|
channel_selections::ChannelSelection,
|
||||||
channels::{BackendConfig, Channel, EmailBackendConfig, CHANNEL_BACKEND_EMAIL},
|
channels::Channel,
|
||||||
csrf::generate_csrf_token,
|
csrf::generate_csrf_token,
|
||||||
email::{MailSender as _, Mailer},
|
email::{MailSender as _, Mailer},
|
||||||
guards,
|
guards,
|
||||||
nav_state::{Breadcrumb, NavState},
|
nav_state::{Breadcrumb, NavState},
|
||||||
projects::Project,
|
projects::Project,
|
||||||
schema::{self, channel_selections, channels},
|
schema::{self, channel_selections, channels, email_channels},
|
||||||
settings::Settings,
|
settings::Settings,
|
||||||
team_memberships::TeamMembership,
|
team_memberships::TeamMembership,
|
||||||
teams::Team,
|
teams::Team,
|
||||||
|
@ -41,9 +41,6 @@ use crate::{
|
||||||
v0_router,
|
v0_router,
|
||||||
};
|
};
|
||||||
|
|
||||||
const VERIFICATION_CODE_LEN: usize = 6;
|
|
||||||
const MAX_VERIFICATION_GUESSES: u32 = 100;
|
|
||||||
|
|
||||||
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(
|
||||||
|
@ -110,7 +107,7 @@ async fn teams_page(
|
||||||
.set_base_path(&base_path)
|
.set_base_path(&base_path)
|
||||||
.push_slug(Breadcrumb {
|
.push_slug(Breadcrumb {
|
||||||
href: "teams".to_string(),
|
href: "teams".to_string(),
|
||||||
label: "Teams".to_string(),
|
label: "New Team".to_string(),
|
||||||
})
|
})
|
||||||
.set_navbar_active_item("teams");
|
.set_navbar_active_item("teams");
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
@ -342,24 +339,8 @@ async fn post_new_channel(
|
||||||
let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?;
|
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?;
|
guards::require_valid_csrf_token(&form_body.csrf_token, ¤t_user, &db_conn).await?;
|
||||||
|
|
||||||
let channel_id = Uuid::now_v7();
|
|
||||||
let channel = match form_body.channel_type.as_str() {
|
let channel = match form_body.channel_type.as_str() {
|
||||||
CHANNEL_BACKEND_EMAIL => db_conn
|
"email" => Channel::create_email_channel(&db_conn, team.id.clone()).await?,
|
||||||
.interact::<_, Result<Channel, AppError>>(move |conn| {
|
|
||||||
Ok(insert_into(channels::table)
|
|
||||||
.values((
|
|
||||||
channels::id.eq(channel_id),
|
|
||||||
channels::team_id.eq(team_id),
|
|
||||||
channels::name.eq("Untitled Email Channel"),
|
|
||||||
channels::backend_config
|
|
||||||
.eq(Into::<BackendConfig>::into(EmailBackendConfig::default())),
|
|
||||||
))
|
|
||||||
.returning(Channel::as_returning())
|
|
||||||
.get_result(conn)
|
|
||||||
.context("Failed to insert new EmailChannel.")?)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap()?,
|
|
||||||
_ => {
|
_ => {
|
||||||
return Err(AppError::BadRequestError(
|
return Err(AppError::BadRequestError(
|
||||||
"Channel type not recognized.".to_string(),
|
"Channel type not recognized.".to_string(),
|
||||||
|
@ -418,29 +399,31 @@ async fn channel_page(
|
||||||
})
|
})
|
||||||
.set_navbar_active_item("channels");
|
.set_navbar_active_item("channels");
|
||||||
|
|
||||||
match channel.backend_config {
|
if channel.email_data.is_some() {
|
||||||
BackendConfig::Email(_) => {
|
#[derive(Template)]
|
||||||
#[derive(Template)]
|
#[template(path = "channel-email.html")]
|
||||||
#[template(path = "channel-email.html")]
|
struct ResponseTemplate {
|
||||||
struct ResponseTemplate {
|
base_path: String,
|
||||||
base_path: String,
|
channel: Channel,
|
||||||
channel: Channel,
|
csrf_token: String,
|
||||||
csrf_token: String,
|
nav_state: NavState,
|
||||||
nav_state: NavState,
|
}
|
||||||
|
Ok(Html(
|
||||||
|
ResponseTemplate {
|
||||||
|
base_path,
|
||||||
|
channel,
|
||||||
|
csrf_token,
|
||||||
|
nav_state,
|
||||||
}
|
}
|
||||||
Ok(Html(
|
.render()?,
|
||||||
ResponseTemplate {
|
))
|
||||||
base_path,
|
} else if channel.slack_data.is_some() {
|
||||||
channel,
|
Err(anyhow::anyhow!("Slack channel config page is not yet implemented.").into())
|
||||||
csrf_token,
|
} else {
|
||||||
nav_state,
|
Err(anyhow::anyhow!(
|
||||||
}
|
"Channel doesn't have a recognized variant for which to render a config page."
|
||||||
.render()?,
|
)
|
||||||
))
|
.into())
|
||||||
}
|
|
||||||
BackendConfig::Slack(_) => {
|
|
||||||
Err(anyhow::anyhow!("Slack channel config page is not yet implemented.").into())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -490,28 +473,6 @@ async fn update_channel(
|
||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to query a channel from the database by ID and team, and
|
|
||||||
* return an appropriate error if no such channel exists.
|
|
||||||
*/
|
|
||||||
fn get_channel_by_params(
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
team_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
) -> Result<Channel, AppError> {
|
|
||||||
match Channel::all()
|
|
||||||
.filter(Channel::with_id(channel_id))
|
|
||||||
.filter(Channel::with_team(team_id))
|
|
||||||
.first(conn)
|
|
||||||
{
|
|
||||||
diesel::QueryResult::Err(diesel::result::Error::NotFound) => Err(AppError::NotFoundError(
|
|
||||||
"Channel with that team and ID not found.".to_string(),
|
|
||||||
)),
|
|
||||||
diesel::QueryResult::Err(err) => Err(err.into()),
|
|
||||||
diesel::QueryResult::Ok(channel) => Ok(channel),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct UpdateChannelEmailRecipientFormBody {
|
struct UpdateChannelEmailRecipientFormBody {
|
||||||
// Yes it's a mouthful, but it's only used twice
|
// Yes it's a mouthful, but it's only used twice
|
||||||
|
@ -542,41 +503,49 @@ async fn update_channel_email_recipient(
|
||||||
|
|
||||||
let verification_code: String = rand::thread_rng()
|
let verification_code: String = rand::thread_rng()
|
||||||
.sample_iter(&Uniform::try_from(0..9).unwrap())
|
.sample_iter(&Uniform::try_from(0..9).unwrap())
|
||||||
.take(VERIFICATION_CODE_LEN)
|
.take(6)
|
||||||
.map(|n| n.to_string())
|
.map(|n| n.to_string())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
{
|
let verification_code_copy = verification_code.clone();
|
||||||
let verification_code = verification_code.clone();
|
let recipient_copy = form_body.recipient.clone();
|
||||||
let recipient = form_body.recipient.clone();
|
|
||||||
let channel_id = channel_id.clone();
|
let channel_id_filter = Channel::with_id(channel_id.clone());
|
||||||
let team_id = team_id.clone();
|
let email_channel_id_filter = email_channels::id.eq(channel_id.clone());
|
||||||
db_conn
|
let team_filter = Channel::with_team(team_id.clone());
|
||||||
.interact(move |conn| {
|
let updated_rows = db_conn
|
||||||
// TODO: transaction retries
|
.interact(move |conn| {
|
||||||
conn.transaction::<_, AppError, _>(move |conn| {
|
if Channel::all()
|
||||||
let channel = get_channel_by_params(conn, team_id, channel_id)?;
|
.filter(channel_id_filter)
|
||||||
let new_config = BackendConfig::Email(EmailBackendConfig {
|
.filter(team_filter)
|
||||||
recipient,
|
.filter(email_channel_id_filter.clone())
|
||||||
verification_code,
|
.first(conn)
|
||||||
verification_code_guesses: 0,
|
.optional()
|
||||||
..channel.backend_config.try_into()?
|
.context("failed to check whether channel exists under team")
|
||||||
});
|
.map_err(Into::<AppError>::into)?
|
||||||
let num_rows = update(channels::table.filter(Channel::with_id(channel.id)))
|
.is_none()
|
||||||
.set(channels::backend_config.eq(new_config))
|
{
|
||||||
.execute(conn)?;
|
return Err(AppError::NotFoundError(
|
||||||
if num_rows != 1 {
|
"Channel with that team and ID not found.".to_string(),
|
||||||
return Err(anyhow!(
|
));
|
||||||
"Updating EmailChannel recipient, the channel was found but {} rows were updated.",
|
}
|
||||||
num_rows
|
update(email_channels::table.filter(email_channel_id_filter))
|
||||||
)
|
.set((
|
||||||
.into());
|
email_channels::recipient.eq(recipient_copy),
|
||||||
}
|
email_channels::verification_code.eq(verification_code_copy),
|
||||||
Ok(())
|
email_channels::verification_code_guesses.eq(0),
|
||||||
})
|
))
|
||||||
})
|
.execute(conn)
|
||||||
.await
|
.map_err(Into::<AppError>::into)
|
||||||
.unwrap()?;
|
})
|
||||||
|
.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!(
|
tracing::debug!(
|
||||||
|
@ -631,55 +600,49 @@ async fn verify_email(
|
||||||
guards::require_valid_csrf_token(&form_body.csrf_token, ¤t_user, &db_conn).await?;
|
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?;
|
guards::require_team_membership(¤t_user, &team_id, &db_conn).await?;
|
||||||
|
|
||||||
if form_body.code.len() != VERIFICATION_CODE_LEN {
|
let channel_id_filter = Channel::with_id(channel_id.clone());
|
||||||
return Err(AppError::BadRequestError(format!(
|
let email_channel_id_filter = email_channels::id.eq(channel_id.clone());
|
||||||
"Verification code must be {} characters long.",
|
let team_filter = Channel::with_team(team_id.clone());
|
||||||
VERIFICATION_CODE_LEN
|
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(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
|
||||||
let channel_id = channel_id.clone();
|
|
||||||
let team_id = team_id.clone();
|
|
||||||
let verification_code = form_body.code;
|
|
||||||
db_conn
|
|
||||||
.interact(move |conn| {
|
|
||||||
conn.transaction::<(), AppError, _>(move |conn| {
|
|
||||||
let channel = get_channel_by_params(conn, team_id, channel_id)?;
|
|
||||||
let config: EmailBackendConfig = channel.backend_config.try_into()?;
|
|
||||||
if config.verified {
|
|
||||||
return Err(AppError::BadRequestError(
|
|
||||||
"Channel's email address is already verified.".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if config.verification_code_guesses > MAX_VERIFICATION_GUESSES {
|
|
||||||
return Err(AppError::BadRequestError(
|
|
||||||
"Verification expired.".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let new_config = if config.verification_code == verification_code {
|
|
||||||
EmailBackendConfig {
|
|
||||||
verified: true,
|
|
||||||
verification_code: "".to_string(),
|
|
||||||
verification_code_guesses: 0,
|
|
||||||
..config
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
EmailBackendConfig {
|
|
||||||
verification_code_guesses: config.verification_code_guesses + 1,
|
|
||||||
..config
|
|
||||||
}
|
|
||||||
};
|
|
||||||
update(channels::table.filter(Channel::with_id(channel_id)))
|
|
||||||
.set(channels::backend_config.eq(Into::<BackendConfig>::into(new_config)))
|
|
||||||
.execute(conn)?;
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap()?;
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Redirect::to(&format!(
|
Ok(Redirect::to(&format!(
|
||||||
"{}/teams/{}/channels/{}",
|
"{}/teams/{}/channels/{}",
|
||||||
base_path,
|
base_path,
|
||||||
|
|
|
@ -31,7 +31,6 @@ diesel::table! {
|
||||||
team_id -> Uuid,
|
team_id -> Uuid,
|
||||||
name -> Text,
|
name -> Text,
|
||||||
enable_by_default -> Bool,
|
enable_by_default -> Bool,
|
||||||
backend_config -> Jsonb,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,12 +42,21 @@ 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,
|
||||||
channel_id -> Uuid,
|
project_id -> Uuid,
|
||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
sent_at -> Nullable<Timestamptz>,
|
|
||||||
message -> Text,
|
message -> Text,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -61,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,
|
||||||
|
@ -89,8 +106,10 @@ diesel::joinable!(channel_selections -> channels (channel_id));
|
||||||
diesel::joinable!(channel_selections -> projects (project_id));
|
diesel::joinable!(channel_selections -> projects (project_id));
|
||||||
diesel::joinable!(channels -> teams (team_id));
|
diesel::joinable!(channels -> teams (team_id));
|
||||||
diesel::joinable!(csrf_tokens -> users (user_id));
|
diesel::joinable!(csrf_tokens -> users (user_id));
|
||||||
diesel::joinable!(messages -> channels (channel_id));
|
diesel::joinable!(email_channels -> channels (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));
|
||||||
|
|
||||||
|
@ -100,8 +119,10 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||||
channel_selections,
|
channel_selections,
|
||||||
channels,
|
channels,
|
||||||
csrf_tokens,
|
csrf_tokens,
|
||||||
|
email_channels,
|
||||||
messages,
|
messages,
|
||||||
projects,
|
projects,
|
||||||
|
slack_channels,
|
||||||
team_memberships,
|
team_memberships,
|
||||||
teams,
|
teams,
|
||||||
users,
|
users,
|
||||||
|
|
|
@ -14,7 +14,6 @@ use crate::{
|
||||||
api_keys::ApiKey,
|
api_keys::ApiKey,
|
||||||
app_error::AppError,
|
app_error::AppError,
|
||||||
app_state::{AppState, DbConn},
|
app_state::{AppState, DbConn},
|
||||||
channels::{Channel, EmailBackendConfig},
|
|
||||||
email::{MailSender as _, Mailer},
|
email::{MailSender as _, Mailer},
|
||||||
projects::Project,
|
projects::Project,
|
||||||
schema::{api_keys, projects},
|
schema::{api_keys, projects},
|
||||||
|
@ -41,59 +40,56 @@ async fn say_get(
|
||||||
DbConn(db_conn): DbConn,
|
DbConn(db_conn): DbConn,
|
||||||
Query(query): Query<SayQuery>,
|
Query(query): Query<SayQuery>,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
// TODO: do some validation of message contents
|
let key = query.key.clone();
|
||||||
let api_key = {
|
let maybe_api_key = db_conn
|
||||||
let query_key = query.key.clone();
|
.interact(move |conn| {
|
||||||
db_conn
|
update(api_keys::table.filter(ApiKey::with_id(key)))
|
||||||
.interact::<_, Result<ApiKey, AppError>>(move |conn| {
|
.set(api_keys::last_used_at.eq(diesel::dsl::now))
|
||||||
update(api_keys::table.filter(ApiKey::with_id(query_key)))
|
.returning(ApiKey::as_returning())
|
||||||
.set(api_keys::last_used_at.eq(diesel::dsl::now))
|
.get_result(conn)
|
||||||
.returning(ApiKey::as_returning())
|
.optional()
|
||||||
.get_result(conn)
|
})
|
||||||
.optional()
|
.await
|
||||||
.context("failed to get API key")?
|
.unwrap()
|
||||||
.ok_or(AppError::ForbiddenError("Key not accepted.".to_string()))
|
.context("unable to get API key")?;
|
||||||
})
|
let api_key = match maybe_api_key {
|
||||||
.await
|
Some(api_key) => api_key,
|
||||||
.unwrap()?
|
None => return Err(AppError::ForbiddenError("key not accepted".to_string())),
|
||||||
};
|
|
||||||
|
|
||||||
let selected_channels = {
|
|
||||||
let project_name = query.project.to_lowercase();
|
|
||||||
db_conn
|
|
||||||
.interact::<_, Result<Vec<Channel>, AppError>>(move |conn| {
|
|
||||||
conn.transaction(move |conn| {
|
|
||||||
let project = match Project::all()
|
|
||||||
.filter(Project::with_team(api_key.team_id))
|
|
||||||
.filter(Project::with_name(project_name.clone()))
|
|
||||||
.first(conn)
|
|
||||||
.optional()
|
|
||||||
.context("failed to load project")?
|
|
||||||
{
|
|
||||||
Some(project) => project,
|
|
||||||
None => insert_into(projects::table)
|
|
||||||
.values((
|
|
||||||
projects::id.eq(Uuid::now_v7()),
|
|
||||||
projects::team_id.eq(api_key.team_id),
|
|
||||||
projects::name.eq(project_name),
|
|
||||||
))
|
|
||||||
.get_result(conn)
|
|
||||||
.context("failed to insert project")?,
|
|
||||||
};
|
|
||||||
Ok(project
|
|
||||||
.selected_channels()
|
|
||||||
.load(conn)
|
|
||||||
.context("failed to load selected channels")?)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap()?
|
|
||||||
};
|
};
|
||||||
|
let project_name = query.project.to_lowercase();
|
||||||
|
let selected_channels = db_conn
|
||||||
|
.interact(move |conn| {
|
||||||
|
insert_into(projects::table)
|
||||||
|
.values((
|
||||||
|
projects::id.eq(Uuid::now_v7()),
|
||||||
|
projects::team_id.eq(api_key.team_id.clone()),
|
||||||
|
projects::name.eq(project_name.clone()),
|
||||||
|
))
|
||||||
|
.on_conflict((projects::team_id, projects::name))
|
||||||
|
.do_nothing()
|
||||||
|
.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
|
||||||
|
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")?;
|
||||||
|
|
||||||
for channel in selected_channels {
|
for channel in selected_channels {
|
||||||
if let Ok(config) = TryInto::<EmailBackendConfig>::try_into(channel.backend_config) {
|
if let Some(email_data) = channel.email_data {
|
||||||
if config.verified {
|
if email_data.verified {
|
||||||
let recipient: lettre::message::Mailbox = config.recipient.parse()?;
|
let recipient: lettre::message::Mailbox = email_data.recipient.parse()?;
|
||||||
let email = crate::email::Message {
|
let email = crate::email::Message {
|
||||||
from: email_settings.message_from.clone().into(),
|
from: email_settings.message_from.clone().into(),
|
||||||
to: recipient.into(),
|
to: recipient.into(),
|
||||||
|
|
|
@ -3,124 +3,123 @@
|
||||||
{% block title %}Shout.dev: Channels{% endblock %}
|
{% block title %}Shout.dev: Channels{% endblock %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
{% if let BackendConfig::Email(email_data) = channel.backend_config %}
|
{% let email_data = channel.email_data.clone().unwrap() %}
|
||||||
{% include "breadcrumbs.html" %}
|
{% include "breadcrumbs.html" %}
|
||||||
<main class="container mt-5">
|
<main class="container mt-5">
|
||||||
<section class="mb-4">
|
<section class="mb-4">
|
||||||
<h1>Channel Configuration</h1>
|
<h1>Channel Configuration</h1>
|
||||||
</section>
|
</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">
|
<section class="mb-4">
|
||||||
<form
|
<form
|
||||||
method="post"
|
method="post"
|
||||||
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-channel"
|
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/verify-email"
|
||||||
>
|
>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="channel-name-input" class="form-label">Channel Name</label>
|
<label for="channel-recipient-verification-code" class="form-label">
|
||||||
|
Verification Code
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
id="channel-name-input"
|
name="code"
|
||||||
name="name"
|
id="channel-recipient-verification-code"
|
||||||
value="{{ channel.name }}"
|
aria-describedby="channel-recipient-verification-code-help"
|
||||||
>
|
>
|
||||||
</div>
|
<div id="channel-recipient-verification-code-help" class="form-text">
|
||||||
<div class="mb-3">
|
Enter the most recent Shout.dev verification code for this address.
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input
|
<input
|
||||||
class="form-check-input"
|
type="submit"
|
||||||
{% if channel.enable_by_default %}
|
form="email-verification-form"
|
||||||
checked=""
|
class="btn btn-link align-baseline p-0"
|
||||||
{% endif %}
|
type="submit"
|
||||||
type="checkbox"
|
style="font-size: inherit;"
|
||||||
name="enable_by_default"
|
value="Re-send verification email"
|
||||||
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>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button class="btn btn-primary" type="submit">Save</button>
|
<button class="btn btn-primary" type="submit">Verify</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
|
||||||
<section class="mb-4">
|
|
||||||
<form
|
<form
|
||||||
|
id="email-verification-form"
|
||||||
method="post"
|
method="post"
|
||||||
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-email-recipient"
|
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-email-recipient"
|
||||||
>
|
>
|
||||||
<div class="mb-3">
|
<input type="hidden" name="recipient" value="{{ email_data.recipient }}">
|
||||||
<label for="channel-recipient-input" class="form-label">Recipient Email</label>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<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>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{% if email_data.recipient != "" && !email_data.verified %}
|
{% endif %}
|
||||||
<section class="mb-4">
|
</main>
|
||||||
<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>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
Loading…
Add table
Reference in a new issue